Compare commits

..

2 Commits

Author SHA1 Message Date
Abhimanyu Yadav
8fa75c8da4 Merge branch 'dev' into testing-claude-code 2026-01-22 16:39:44 +05:30
abhi1992002
919cc877ad feat(frontend): enhance UI components with animations and accessibility improvements
### Changes 🏗️
- Integrated `FadeIn` animations in `AgentsSection`, `FeaturedCreators`, `FeaturedSection`, `HeroSection`, and `BecomeACreator` components for improved visual appeal.
- Replaced static elements with `StaggeredList` in `FeaturedCreators` and `AgentsSection` for a more dynamic layout.
- Updated `SearchBar` to use `type="search"` and added `aria-label` for better accessibility.
- Enhanced `StoreCard` with focus-visible styles and keyboard navigation support.
- Refactored `FilterChips` to utilize `FilterChip` component for a more consistent design.

### Checklist 📋
- [x] Verified animations function correctly across components.
- [x] Ensured accessibility improvements are in place and tested.
- [x] Confirmed UI consistency with design specifications.
2026-01-20 20:12:33 +05:30
39 changed files with 1339 additions and 965 deletions

View File

@@ -128,7 +128,7 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
exitOnceUploaded: true
e2e_test:
test:
runs-on: big-boi
needs: setup
strategy:
@@ -258,39 +258,3 @@ jobs:
- name: Print Final Docker Compose logs
if: always()
run: docker compose -f ../docker-compose.yml logs
integration_test:
runs-on: ubuntu-latest
needs: setup
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22.18.0"
- name: Enable corepack
run: corepack enable
- name: Restore dependencies cache
uses: actions/cache@v4
with:
path: ~/.pnpm-store
key: ${{ needs.setup.outputs.cache-key }}
restore-keys: |
${{ runner.os }}-pnpm-${{ hashFiles('autogpt_platform/frontend/pnpm-lock.yaml') }}
${{ runner.os }}-pnpm-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Generate API client
run: pnpm generate:api
- name: Run Integration Tests
run: pnpm test:unit

View File

@@ -120,6 +120,7 @@
},
"devDependencies": {
"@chromatic-com/storybook": "4.1.2",
"happy-dom": "20.3.4",
"@opentelemetry/instrumentation": "0.209.0",
"@playwright/test": "1.56.1",
"@storybook/addon-a11y": "9.1.5",
@@ -130,7 +131,6 @@
"@tanstack/eslint-plugin-query": "5.91.2",
"@tanstack/react-query-devtools": "5.90.2",
"@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.2",
"@types/canvas-confetti": "1.9.0",
"@types/lodash": "4.17.20",
@@ -148,7 +148,6 @@
"eslint": "8.57.1",
"eslint-config-next": "15.5.7",
"eslint-plugin-storybook": "9.1.5",
"happy-dom": "20.3.4",
"import-in-the-middle": "2.0.2",
"msw": "2.11.6",
"msw-storybook-addon": "2.0.6",

View File

@@ -306,9 +306,6 @@ importers:
'@testing-library/dom':
specifier: 10.4.1
version: 10.4.1
'@testing-library/jest-dom':
specifier: 6.9.1
version: 6.9.1
'@testing-library/react':
specifier: 16.3.2
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.5(@types/react@18.3.17))(@types/react@18.3.17)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)

View File

@@ -80,7 +80,6 @@ export const AgentInfo = ({
const allVersions = storeData?.versions
? storeData.versions
.map((versionStr: string) => parseInt(versionStr, 10))
.filter((versionNum: number) => !isNaN(versionNum))
.sort((a: number, b: number) => b - a)
.map((versionNum: number) => ({
version: versionNum,

View File

@@ -5,6 +5,8 @@ import {
CarouselContent,
CarouselItem,
} from "@/components/__legacy__/ui/carousel";
import { FadeIn } from "@/components/molecules/FadeIn/FadeIn";
import { StaggeredList } from "@/components/molecules/StaggeredList/StaggeredList";
import { useAgentsSection } from "./useAgentsSection";
import { StoreAgent } from "@/app/api/__generated__/models/storeAgent";
import { StoreCard } from "../StoreCard/StoreCard";
@@ -41,12 +43,14 @@ export const AgentsSection = ({
return (
<div className="flex flex-col items-center justify-center">
<div className="w-full max-w-[1360px]">
<h2
style={{ marginBottom: margin }}
className="font-poppins text-lg font-semibold text-[#282828] dark:text-neutral-200"
>
{sectionTitle}
</h2>
<FadeIn direction="left" duration={0.5}>
<h2
style={{ marginBottom: margin }}
className="font-poppins text-lg font-semibold text-neutral-800 dark:text-neutral-200"
>
{sectionTitle}
</h2>
</FadeIn>
{!displayedAgents || displayedAgents.length === 0 ? (
<div className="text-center text-gray-500 dark:text-gray-400">
No agents found
@@ -54,32 +58,38 @@ export const AgentsSection = ({
) : (
<>
{/* Mobile Carousel View */}
<Carousel
className="md:hidden"
opts={{
loop: true,
}}
>
<CarouselContent>
{displayedAgents.map((agent, index) => (
<CarouselItem key={index} className="min-w-64 max-w-71">
<StoreCard
agentName={agent.agent_name}
agentImage={agent.agent_image}
description={agent.description}
runs={agent.runs}
rating={agent.rating}
avatarSrc={agent.creator_avatar}
creatorName={agent.creator}
hideAvatar={hideAvatars}
onClick={() => handleCardClick(agent.creator, agent.slug)}
/>
</CarouselItem>
))}
</CarouselContent>
</Carousel>
<FadeIn direction="up" className="md:hidden">
<Carousel
opts={{
loop: true,
}}
>
<CarouselContent>
{displayedAgents.map((agent, index) => (
<CarouselItem key={index} className="min-w-64 max-w-71">
<StoreCard
agentName={agent.agent_name}
agentImage={agent.agent_image}
description={agent.description}
runs={agent.runs}
rating={agent.rating}
avatarSrc={agent.creator_avatar}
creatorName={agent.creator}
hideAvatar={hideAvatars}
onClick={() => handleCardClick(agent.creator, agent.slug)}
/>
</CarouselItem>
))}
</CarouselContent>
</Carousel>
</FadeIn>
<div className="hidden grid-cols-1 place-items-center gap-6 md:grid md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
{/* Desktop Grid View with Staggered Animation */}
<StaggeredList
direction="up"
staggerDelay={0.08}
className="hidden grid-cols-1 place-items-center gap-6 md:grid md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4"
>
{displayedAgents.map((agent, index) => (
<StoreCard
key={index}
@@ -94,7 +104,7 @@ export const AgentsSection = ({
onClick={() => handleCardClick(agent.creator, agent.slug)}
/>
))}
</div>
</StaggeredList>
</>
)}
</div>

View File

@@ -38,7 +38,7 @@ export function BecomeACreator({
<PublishAgentModal
trigger={
<button className="inline-flex h-[48px] cursor-pointer items-center justify-center rounded-[38px] bg-neutral-800 px-8 py-3 transition-colors hover:bg-neutral-700 dark:bg-neutral-700 dark:hover:bg-neutral-600 md:h-[56px] md:px-10 md:py-4 lg:h-[68px] lg:px-12 lg:py-5">
<button className="inline-flex h-[48px] cursor-pointer items-center justify-center rounded-[38px] bg-neutral-800 px-8 py-3 transition-colors hover:bg-neutral-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 dark:bg-neutral-700 dark:hover:bg-neutral-600 dark:focus-visible:ring-neutral-50 md:h-[56px] md:px-10 md:py-4 lg:h-[68px] lg:px-12 lg:py-5">
<span className="whitespace-nowrap font-poppins text-base font-medium leading-normal text-neutral-50 md:text-lg md:leading-relaxed lg:text-xl lg:leading-7">
{buttonText}
</span>

View File

@@ -20,9 +20,18 @@ export const CreatorCard = ({
}: CreatorCardProps) => {
return (
<div
className={`h-[264px] w-full px-[18px] pb-5 pt-6 ${backgroundColor(index)} inline-flex cursor-pointer flex-col items-start justify-start gap-3.5 rounded-[26px] transition-all duration-200 hover:brightness-95`}
className={`h-[264px] w-full px-[18px] pb-5 pt-6 ${backgroundColor(index)} inline-flex cursor-pointer flex-col items-start justify-start gap-3.5 rounded-[26px] transition-[filter] duration-200 hover:brightness-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 dark:focus-visible:ring-neutral-50`}
onClick={onClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick();
}
}}
data-testid="creator-card"
role="button"
tabIndex={0}
aria-label={`View ${creatorName}'s profile - ${agentsUploaded} agents`}
>
<div className="relative h-[64px] w-[64px]">
<div className="absolute inset-0 overflow-hidden rounded-full">

View File

@@ -1,5 +1,7 @@
"use client";
import { FadeIn } from "@/components/molecules/FadeIn/FadeIn";
import { StaggeredList } from "@/components/molecules/StaggeredList/StaggeredList";
import { CreatorCard } from "../CreatorCard/CreatorCard";
import { useFeaturedCreators } from "./useFeaturedCreators";
import { Creator } from "@/app/api/__generated__/models/creator";
@@ -19,11 +21,17 @@ export const FeaturedCreators = ({
return (
<div className="flex w-full flex-col items-center justify-center">
<div className="w-full max-w-[1360px]">
<h2 className="mb-9 font-poppins text-lg font-semibold text-neutral-800 dark:text-neutral-200">
{title}
</h2>
<FadeIn direction="left" duration={0.5}>
<h2 className="mb-9 font-poppins text-lg font-semibold text-neutral-800 dark:text-neutral-200">
{title}
</h2>
</FadeIn>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
<StaggeredList
direction="up"
staggerDelay={0.1}
className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4"
>
{displayedCreators.map((creator, index) => (
<CreatorCard
key={index}
@@ -35,7 +43,7 @@ export const FeaturedCreators = ({
index={index}
/>
))}
</div>
</StaggeredList>
</div>
</div>
);

View File

@@ -8,6 +8,7 @@ import {
CarouselNext,
CarouselIndicator,
} from "@/components/__legacy__/ui/carousel";
import { FadeIn } from "@/components/molecules/FadeIn/FadeIn";
import Link from "next/link";
import { useFeaturedSection } from "./useFeaturedSection";
import { StoreAgent } from "@/app/api/__generated__/models/storeAgent";
@@ -25,40 +26,44 @@ export const FeaturedSection = ({ featuredAgents }: FeaturedSectionProps) => {
return (
<section className="w-full">
<h2 className="mb-8 font-poppins text-2xl font-semibold leading-7 text-neutral-800 dark:text-neutral-200">
Featured agents
</h2>
<FadeIn direction="left" duration={0.5}>
<h2 className="mb-8 font-poppins text-2xl font-semibold leading-7 text-neutral-800 dark:text-neutral-200">
Featured agents
</h2>
</FadeIn>
<Carousel
opts={{
align: "center",
containScroll: "trimSnaps",
}}
>
<CarouselContent>
{featuredAgents.map((agent, index) => (
<CarouselItem
key={index}
className="h-[480px] md:basis-1/2 lg:basis-1/3"
>
<Link
href={`/marketplace/agent/${encodeURIComponent(agent.creator)}/${encodeURIComponent(agent.slug)}`}
className="block h-full"
<FadeIn direction="up" duration={0.6} delay={0.1}>
<Carousel
opts={{
align: "center",
containScroll: "trimSnaps",
}}
>
<CarouselContent>
{featuredAgents.map((agent, index) => (
<CarouselItem
key={index}
className="h-[480px] md:basis-1/2 lg:basis-1/3"
>
<FeaturedAgentCard
agent={agent}
backgroundColor={getBackgroundColor(index)}
/>
</Link>
</CarouselItem>
))}
</CarouselContent>
<div className="relative mt-4">
<CarouselIndicator />
<CarouselPrevious afterClick={handlePrevSlide} />
<CarouselNext afterClick={handleNextSlide} />
</div>
</Carousel>
<Link
href={`/marketplace/agent/${encodeURIComponent(agent.creator)}/${encodeURIComponent(agent.slug)}`}
className="block h-full"
>
<FeaturedAgentCard
agent={agent}
backgroundColor={getBackgroundColor(index)}
/>
</Link>
</CarouselItem>
))}
</CarouselContent>
<div className="relative mt-4">
<CarouselIndicator />
<CarouselPrevious afterClick={handlePrevSlide} />
<CarouselNext afterClick={handleNextSlide} />
</div>
</Carousel>
</FadeIn>
</section>
);
};

View File

@@ -1,6 +1,6 @@
"use client";
import { Badge } from "@/components/__legacy__/ui/badge";
import { FilterChip } from "@/components/atoms/FilterChip/FilterChip";
import { useFilterChips } from "./useFilterChips";
interface FilterChipsProps {
@@ -9,8 +9,6 @@ interface FilterChipsProps {
multiSelect?: boolean;
}
// Some flaws in its logic
// FRONTEND-TODO : This needs to be fixed
export const FilterChips = ({
badges,
onFilterChange,
@@ -22,18 +20,20 @@ export const FilterChips = ({
});
return (
<div className="flex h-auto min-h-8 flex-wrap items-center justify-center gap-3 lg:min-h-14 lg:justify-start lg:gap-5">
<div
className="flex h-auto min-h-8 flex-wrap items-center justify-center gap-3 lg:min-h-14 lg:justify-start lg:gap-5"
role="group"
aria-label="Filter options"
>
{badges.map((badge) => (
<Badge
<FilterChip
key={badge}
variant={selectedFilters.includes(badge) ? "secondary" : "outline"}
className="mb-2 flex cursor-pointer items-center justify-center gap-2 rounded-full border border-black/50 px-3 py-1 dark:border-white/50 lg:mb-3 lg:gap-2.5 lg:px-6 lg:py-2"
label={badge}
selected={selectedFilters.includes(badge)}
onClick={() => handleBadgeClick(badge)}
>
<div className="text-sm font-light tracking-tight text-[#474747] dark:text-[#e0e0e0] lg:text-xl lg:font-medium lg:leading-9">
{badge}
</div>
</Badge>
size="lg"
className="mb-2 lg:mb-3"
/>
))}
</div>
);

View File

@@ -1,5 +1,6 @@
"use client";
import { FadeIn } from "@/components/molecules/FadeIn/FadeIn";
import { FilterChips } from "../FilterChips/FilterChips";
import { SearchBar } from "../SearchBar/SearchBar";
import { useHeroSection } from "./useHeroSection";
@@ -9,30 +10,36 @@ export const HeroSection = () => {
return (
<div className="mb-2 mt-8 flex flex-col items-center justify-center px-4 sm:mb-4 sm:mt-12 sm:px-6 md:mb-6 md:mt-16 lg:my-24 lg:px-8 xl:my-16">
<div className="w-full max-w-3xl lg:max-w-4xl xl:max-w-5xl">
<div className="mb-4 text-center md:mb-8">
<h1 className="text-center">
<span className="font-poppins text-[48px] font-semibold leading-[54px] text-neutral-950 dark:text-neutral-50">
Explore AI agents built for{" "}
</span>
<span className="font-poppins text-[48px] font-semibold leading-[54px] text-violet-600">
you
</span>
<br />
<span className="font-poppins text-[48px] font-semibold leading-[54px] text-neutral-950 dark:text-neutral-50">
by the{" "}
</span>
<span className="font-poppins text-[48px] font-semibold leading-[54px] text-blue-500">
community
</span>
</h1>
</div>
<h3 className="mb:text-2xl mb-6 text-center font-sans text-xl font-normal leading-loose text-neutral-700 dark:text-neutral-300 md:mb-12">
Bringing you AI agents designed by thinkers from around the world
</h3>
<div className="mb-4 flex justify-center sm:mb-5">
<SearchBar height="h-[74px]" />
</div>
<div>
<FadeIn direction="down" duration={0.6} delay={0}>
<div className="mb-4 text-center md:mb-8">
<h1 className="text-center">
<span className="font-poppins text-[48px] font-semibold leading-[54px] text-neutral-950 dark:text-neutral-50">
Explore AI agents built for{" "}
</span>
<span className="font-poppins text-[48px] font-semibold leading-[54px] text-violet-600">
you
</span>
<br />
<span className="font-poppins text-[48px] font-semibold leading-[54px] text-neutral-950 dark:text-neutral-50">
by the{" "}
</span>
<span className="font-poppins text-[48px] font-semibold leading-[54px] text-blue-500">
community
</span>
</h1>
</div>
</FadeIn>
<FadeIn direction="up" duration={0.6} delay={0.15}>
<h3 className="mb:text-2xl mb-6 text-center font-sans text-xl font-normal leading-loose text-neutral-700 dark:text-neutral-300 md:mb-12">
Bringing you AI agents designed by thinkers from around the world
</h3>
</FadeIn>
<FadeIn direction="up" duration={0.5} delay={0.3}>
<div className="mb-4 flex justify-center sm:mb-5">
<SearchBar height="h-[74px]" />
</div>
</FadeIn>
<FadeIn direction="up" duration={0.5} delay={0.4}>
<div className="flex justify-center">
<FilterChips
badges={searchTerms}
@@ -40,7 +47,7 @@ export const HeroSection = () => {
multiSelect={false}
/>
</div>
</div>
</FadeIn>
</div>
</div>
);

View File

@@ -1,173 +0,0 @@
import { describe, expect, test, afterEach } from "vitest";
import {
render,
screen,
waitFor,
act,
} from "@/tests/integrations/test-utils";
import { MainAgentPage } from "../MainAgentPage";
import { server } from "@/mocks/mock-server";
import { getGetV2GetSpecificAgentMockHandler422 } from "@/app/api/__generated__/endpoints/store/store.msw";
import { create500Handler } from "@/tests/integrations/helpers/create-500-handler";
import {
mockAuthenticatedUser,
mockUnauthenticatedUser,
resetAuthState,
} from "@/tests/integrations/helpers/mock-supabase-auth";
const defaultParams = {
creator: "test-creator",
slug: "test-agent",
};
describe("MainAgentPage", () => {
afterEach(() => {
resetAuthState();
});
describe("rendering", () => {
test("renders agent info with title", async () => {
render(<MainAgentPage params={defaultParams} />);
await waitFor(() => {
expect(screen.getByTestId("agent-title")).toBeInTheDocument();
});
});
test("renders agent creator info", async () => {
render(<MainAgentPage params={defaultParams} />);
await waitFor(() => {
expect(screen.getByTestId("agent-creator")).toBeInTheDocument();
});
});
test("renders agent description", async () => {
render(<MainAgentPage params={defaultParams} />);
await waitFor(() => {
expect(screen.getByTestId("agent-description")).toBeInTheDocument();
});
});
test("renders breadcrumbs with marketplace link", async () => {
render(<MainAgentPage params={defaultParams} />);
await waitFor(() => {
expect(
screen.getByRole("link", { name: /marketplace/i }),
).toBeInTheDocument();
});
});
test("renders download button", async () => {
render(<MainAgentPage params={defaultParams} />);
await waitFor(() => {
expect(screen.getByTestId("agent-download-button")).toBeInTheDocument();
});
});
test("renders similar agents section", async () => {
render(<MainAgentPage params={defaultParams} />);
await waitFor(() => {
expect(
screen.getByText("Similar agents", { exact: false }),
).toBeInTheDocument();
});
});
});
describe("auth state", () => {
test("shows add to library button when authenticated", async () => {
mockAuthenticatedUser();
render(<MainAgentPage params={defaultParams} />);
await waitFor(() => {
expect(
screen.getByTestId("agent-add-library-button"),
).toBeInTheDocument();
});
});
test("hides add to library button when not authenticated", async () => {
mockUnauthenticatedUser();
render(<MainAgentPage params={defaultParams} />);
await waitFor(() => {
expect(screen.getByTestId("agent-title")).toBeInTheDocument();
});
expect(
screen.queryByTestId("agent-add-library-button"),
).not.toBeInTheDocument();
});
test("renders page correctly when logged out", async () => {
mockUnauthenticatedUser();
render(<MainAgentPage params={defaultParams} />);
await waitFor(() => {
expect(screen.getByTestId("agent-title")).toBeInTheDocument();
});
expect(screen.getByTestId("agent-download-button")).toBeInTheDocument();
});
test("renders page correctly when logged in", async () => {
mockAuthenticatedUser();
render(<MainAgentPage params={defaultParams} />);
await waitFor(() => {
expect(screen.getByTestId("agent-title")).toBeInTheDocument();
});
expect(
screen.getByTestId("agent-add-library-button"),
).toBeInTheDocument();
});
});
describe("error handling", () => {
test("displays error when agent API returns 422", async () => {
server.use(getGetV2GetSpecificAgentMockHandler422());
render(<MainAgentPage params={defaultParams} />);
await waitFor(() => {
expect(
screen.getByText("Failed to load agent data", { exact: false }),
).toBeInTheDocument();
});
await act(async () => {});
});
test("displays error when API returns 500", async () => {
server.use(
create500Handler("get", "*/api/store/agents/test-creator/test-agent"),
);
render(<MainAgentPage params={defaultParams} />);
await waitFor(() => {
expect(
screen.getByText("Failed to load agent data", { exact: false }),
).toBeInTheDocument();
});
await act(async () => {});
});
test("retry button is visible on error", async () => {
server.use(getGetV2GetSpecificAgentMockHandler422());
render(<MainAgentPage params={defaultParams} />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /try again/i }),
).toBeInTheDocument();
});
await act(async () => {});
});
});
});

View File

@@ -1,131 +0,0 @@
import { describe, expect, test, afterEach } from "vitest";
import { cleanup, render, screen, waitFor } from "@/tests/integrations/test-utils";
import { MainCreatorPage } from "../MainCreatorPage";
import { server } from "@/mocks/mock-server";
import {
getGetV2GetCreatorDetailsMockHandler422,
getGetV2ListStoreAgentsMockHandler422,
} from "@/app/api/__generated__/endpoints/store/store.msw";
import { create500Handler } from "@/tests/integrations/helpers/create-500-handler";
import {
mockAuthenticatedUser,
mockUnauthenticatedUser,
resetAuthState,
} from "@/tests/integrations/helpers/mock-supabase-auth";
const defaultParams = {
creator: "test-creator",
};
describe("MainCreatorPage", () => {
afterEach(() => {
resetAuthState();
});
describe("rendering", () => {
test("renders creator info card", async () => {
render(<MainCreatorPage params={defaultParams} />);
await waitFor(() => {
expect(screen.getByTestId("creator-description")).toBeInTheDocument();
});
});
test("renders breadcrumbs with marketplace link", async () => {
render(<MainCreatorPage params={defaultParams} />);
await waitFor(() => {
expect(
screen.getByRole("link", { name: /marketplace/i }),
).toBeInTheDocument();
});
});
test("renders about section", async () => {
render(<MainCreatorPage params={defaultParams} />);
await waitFor(() => {
expect(screen.getByText("About")).toBeInTheDocument();
});
});
test("renders agents by creator section", async () => {
render(<MainCreatorPage params={defaultParams} />);
await waitFor(() => {
expect(
screen.getByText(/Agents by/i, { exact: false }),
).toBeInTheDocument();
});
});
});
describe("auth state", () => {
test("renders page correctly when logged out", async () => {
mockUnauthenticatedUser();
render(<MainCreatorPage params={defaultParams} />);
await waitFor(() => {
expect(screen.getByTestId("creator-description")).toBeInTheDocument();
});
});
test("renders page correctly when logged in", async () => {
mockAuthenticatedUser();
render(<MainCreatorPage params={defaultParams} />);
await waitFor(() => {
expect(screen.getByTestId("creator-description")).toBeInTheDocument();
});
});
});
describe("error handling", () => {
test("displays error when creator details API returns 422", async () => {
server.use(getGetV2GetCreatorDetailsMockHandler422());
render(<MainCreatorPage params={defaultParams} />);
await waitFor(() => {
expect(
screen.getByText("Failed to load creator data", { exact: false }),
).toBeInTheDocument();
});
});
test("displays error when creator agents API returns 422", async () => {
server.use(getGetV2ListStoreAgentsMockHandler422());
render(<MainCreatorPage params={defaultParams} />);
await waitFor(() => {
expect(
screen.getByText("Failed to load creator data", { exact: false }),
).toBeInTheDocument();
});
});
test("displays error when API returns 500", async () => {
server.use(create500Handler("get", "*/api/store/creator/test-creator"));
render(<MainCreatorPage params={defaultParams} />);
await waitFor(() => {
expect(
screen.getByText("Failed to load creator data", { exact: false }),
).toBeInTheDocument();
});
});
test("retry button is visible on error", async () => {
server.use(getGetV2GetCreatorDetailsMockHandler422());
render(<MainCreatorPage params={defaultParams} />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /try again/i }),
).toBeInTheDocument();
});
});
});
});

View File

@@ -1,5 +1,6 @@
"use client";
import { Separator } from "@/components/__legacy__/ui/separator";
import { Separator } from "@/components/atoms/Separator/Separator";
import { FadeIn } from "@/components/molecules/FadeIn/FadeIn";
import { FeaturedSection } from "../FeaturedSection/FeaturedSection";
import { BecomeACreator } from "../BecomeACreator/BecomeACreator";
import { HeroSection } from "../HeroSection/HeroSection";
@@ -54,11 +55,13 @@ export const MainMarkeplacePage = () => {
<FeaturedCreators featuredCreators={featuredCreators.creators} />
)}
<Separator className="mb-[25px] mt-[60px]" />
<BecomeACreator
title="Become a Creator"
description="Join our ever-growing community of hackers and tinkerers"
buttonText="Become a Creator"
/>
<FadeIn direction="up" duration={0.6}>
<BecomeACreator
title="Become a Creator"
description="Join our ever-growing community of hackers and tinkerers"
buttonText="Become a Creator"
/>
</FadeIn>
</main>
</div>
);

View File

@@ -1,132 +0,0 @@
import { describe, expect, test, afterEach } from "vitest";
import { render, screen, waitFor } from "@/tests/integrations/test-utils";
import { MainMarkeplacePage } from "../MainMarketplacePage";
import { server } from "@/mocks/mock-server";
import {
getGetV2ListStoreAgentsMockHandler422,
getGetV2ListStoreCreatorsMockHandler422,
} from "@/app/api/__generated__/endpoints/store/store.msw";
import { create500Handler } from "@/tests/integrations/helpers/create-500-handler";
import {
mockAuthenticatedUser,
mockUnauthenticatedUser,
resetAuthState,
} from "@/tests/integrations/helpers/mock-supabase-auth";
describe("MainMarketplacePage", () => {
afterEach(() => {
resetAuthState();
});
describe("rendering", () => {
test("renders hero section with search bar", async () => {
render(<MainMarkeplacePage />);
expect(
await screen.findByText("Featured agents", { exact: false }),
).toBeInTheDocument();
expect(screen.getByPlaceholderText(/search/i)).toBeInTheDocument();
});
test("renders featured agents section", async () => {
render(<MainMarkeplacePage />);
expect(
await screen.findByText("Featured agents", { exact: false }),
).toBeInTheDocument();
});
test("renders top agents section", async () => {
render(<MainMarkeplacePage />);
expect(
await screen.findByText("Top Agents", { exact: false }),
).toBeInTheDocument();
});
test("renders featured creators section", async () => {
render(<MainMarkeplacePage />);
expect(
await screen.findByText("Featured creators", { exact: false }),
).toBeInTheDocument();
});
});
describe("auth state", () => {
test("renders page correctly when logged out", async () => {
mockUnauthenticatedUser();
render(<MainMarkeplacePage />);
expect(
await screen.findByText("Featured agents", { exact: false }),
).toBeInTheDocument();
expect(
screen.getByText("Top Agents", { exact: false }),
).toBeInTheDocument();
});
test("renders page correctly when logged in", async () => {
mockAuthenticatedUser();
render(<MainMarkeplacePage />);
expect(
await screen.findByText("Featured agents", { exact: false }),
).toBeInTheDocument();
expect(
screen.getByText("Top Agents", { exact: false }),
).toBeInTheDocument();
});
});
describe("error handling", () => {
test("displays error when featured agents API returns 422", async () => {
server.use(getGetV2ListStoreAgentsMockHandler422());
render(<MainMarkeplacePage />);
await waitFor(() => {
expect(
screen.getByText("Failed to load marketplace data", { exact: false }),
).toBeInTheDocument();
});
});
test("displays error when creators API returns 422", async () => {
server.use(getGetV2ListStoreCreatorsMockHandler422());
render(<MainMarkeplacePage />);
await waitFor(() => {
expect(
screen.getByText("Failed to load marketplace data", { exact: false }),
).toBeInTheDocument();
});
});
test("displays error when API returns 500", async () => {
server.use(create500Handler("get", "*/api/store/agents*"));
render(<MainMarkeplacePage />);
await waitFor(() => {
expect(
screen.getByText("Failed to load marketplace data", { exact: false }),
).toBeInTheDocument();
});
});
test("retry button is visible on error", async () => {
server.use(getGetV2ListStoreAgentsMockHandler422());
render(<MainMarkeplacePage />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /try again/i }),
).toBeInTheDocument();
});
});
});
});

View File

@@ -1,114 +0,0 @@
import { describe, expect, test, afterEach } from "vitest";
import { render, screen, waitFor } from "@/tests/integrations/test-utils";
import { MainSearchResultPage } from "../MainSearchResultPage";
import { server } from "@/mocks/mock-server";
import {
getGetV2ListStoreAgentsMockHandler422,
getGetV2ListStoreCreatorsMockHandler422,
} from "@/app/api/__generated__/endpoints/store/store.msw";
import { create500Handler } from "@/tests/integrations/helpers/create-500-handler";
import {
mockAuthenticatedUser,
mockUnauthenticatedUser,
resetAuthState,
} from "@/tests/integrations/helpers/mock-supabase-auth";
const defaultProps = {
searchTerm: "test-search",
sort: undefined as undefined,
};
describe("MainSearchResultPage", () => {
afterEach(() => {
resetAuthState();
});
describe("rendering", () => {
test("renders search results header with search term", async () => {
render(<MainSearchResultPage {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("Results for:")).toBeInTheDocument();
});
expect(screen.getByText("test-search")).toBeInTheDocument();
});
test("renders search bar", async () => {
render(<MainSearchResultPage {...defaultProps} />);
await waitFor(() => {
expect(screen.getByPlaceholderText(/search/i)).toBeInTheDocument();
});
});
});
describe("auth state", () => {
test("renders page correctly when logged out", async () => {
mockUnauthenticatedUser();
render(<MainSearchResultPage {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("Results for:")).toBeInTheDocument();
});
});
test("renders page correctly when logged in", async () => {
mockAuthenticatedUser();
render(<MainSearchResultPage {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("Results for:")).toBeInTheDocument();
});
});
});
describe("error handling", () => {
test("displays error when agents API returns 422", async () => {
server.use(getGetV2ListStoreAgentsMockHandler422());
render(<MainSearchResultPage {...defaultProps} />);
await waitFor(() => {
expect(
screen.getByText("Failed to load marketplace data", { exact: false }),
).toBeInTheDocument();
});
});
test("displays error when creators API returns 422", async () => {
server.use(getGetV2ListStoreCreatorsMockHandler422());
render(<MainSearchResultPage {...defaultProps} />);
await waitFor(() => {
expect(
screen.getByText("Failed to load marketplace data", { exact: false }),
).toBeInTheDocument();
});
});
test("displays error when API returns 500", async () => {
server.use(create500Handler("get", "*/api/store/agents*"));
render(<MainSearchResultPage {...defaultProps} />);
await waitFor(() => {
expect(
screen.getByText("Failed to load marketplace data", { exact: false }),
).toBeInTheDocument();
});
});
test("retry button is visible on error", async () => {
server.use(getGetV2ListStoreAgentsMockHandler422());
render(<MainSearchResultPage {...defaultProps} />);
await waitFor(() => {
expect(
screen.getByRole("button", { name: /try again/i }),
).toBeInTheDocument();
});
});
});
});

View File

@@ -16,9 +16,9 @@ interface SearchBarProps {
export const SearchBar = ({
placeholder = 'Search for tasks like "optimise SEO"',
backgroundColor = "bg-neutral-100 dark:bg-neutral-800",
iconColor = "text-[#646464] dark:text-neutral-400",
textColor = "text-[#707070] dark:text-neutral-200",
placeholderColor = "text-[#707070] dark:text-neutral-400",
iconColor = "text-neutral-500 dark:text-neutral-400",
textColor = "text-neutral-500 dark:text-neutral-200",
placeholderColor = "text-neutral-500 dark:text-neutral-400",
width = "w-9/10 lg:w-[56.25rem]",
height = "h-[60px]",
}: SearchBarProps) => {
@@ -32,10 +32,13 @@ export const SearchBar = ({
>
<MagnifyingGlassIcon className={`h-5 w-5 md:h-7 md:w-7 ${iconColor}`} />
<input
type="text"
type="search"
name="search"
autoComplete="off"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={placeholder}
aria-label="Search for AI agents"
className={`flex-grow border-none bg-transparent ${textColor} font-sans text-lg font-normal leading-[2.25rem] tracking-tight md:text-xl placeholder:${placeholderColor} focus:outline-none`}
data-testid="store-search-input"
/>

View File

@@ -1,10 +1,25 @@
import Image from "next/image";
import { StarRatingIcons } from "@/components/__legacy__/ui/icons";
import { Star } from "@phosphor-icons/react";
import Avatar, {
AvatarFallback,
AvatarImage,
} from "@/components/atoms/Avatar/Avatar";
function StarRating({ rating }: { rating: number }) {
const stars = [];
const clampedRating = Math.max(0, Math.min(5, rating));
for (let i = 1; i <= 5; i++) {
stars.push(
<Star
key={i}
weight={i <= clampedRating ? "fill" : "regular"}
className="h-4 w-4 text-neutral-900 dark:text-yellow-500"
/>,
);
}
return <>{stars}</>;
}
interface StoreCardProps {
agentName: string;
agentImage: string;
@@ -34,7 +49,7 @@ export const StoreCard: React.FC<StoreCardProps> = ({
return (
<div
className="flex h-[27rem] w-full max-w-md cursor-pointer flex-col items-start rounded-3xl bg-background transition-all duration-300 hover:shadow-lg dark:hover:shadow-gray-700"
className="flex h-[27rem] w-full max-w-md cursor-pointer flex-col items-start rounded-3xl bg-background transition-shadow duration-300 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 dark:hover:shadow-gray-700 dark:focus-visible:ring-neutral-50"
onClick={handleClick}
data-testid="store-card"
role="button"
@@ -76,7 +91,7 @@ export const StoreCard: React.FC<StoreCardProps> = ({
<div className="mt-3 flex w-full flex-1 flex-col px-4">
{/* Second Section: Agent Name and Creator Name */}
<div className="flex w-full flex-col">
<h3 className="line-clamp-2 font-poppins text-2xl font-semibold text-[#272727] dark:text-neutral-100">
<h3 className="line-clamp-2 font-poppins text-2xl font-semibold text-neutral-800 dark:text-neutral-100">
{agentName}
</h3>
{!hideAvatar && creatorName && (
@@ -107,11 +122,11 @@ export const StoreCard: React.FC<StoreCardProps> = ({
{rating.toFixed(1)}
</span>
<div
className="inline-flex items-center"
className="inline-flex items-center gap-0.5"
role="img"
aria-label={`Rating: ${rating.toFixed(1)} out of 5 stars`}
>
{StarRatingIcons(rating)}
<StarRating rating={rating} />
</div>
</div>
</div>

View File

@@ -136,19 +136,16 @@ export const customMutator = async <
response.statusText ||
`HTTP ${response.status}`;
const isTestEnv = process.env.NODE_ENV === 'test';
if (!isTestEnv) {
console.error(
`Request failed ${environment.isServerSide() ? "on server" : "on client"}`,
{
status: response.status,
method,
url: fullUrl.replace(baseUrl, ""), // Show relative URL for cleaner logs
errorMessage,
responseData: responseData || "No response data",
},
);
}
console.error(
`Request failed ${environment.isServerSide() ? "on server" : "on client"}`,
{
status: response.status,
method,
url: fullUrl.replace(baseUrl, ""), // Show relative URL for cleaner logs
errorMessage,
responseData: responseData || "No response data",
},
);
throw new ApiError(errorMessage, response.status, responseData);
}

View File

@@ -0,0 +1,151 @@
import type { Meta, StoryObj } from "@storybook/react";
import { useState } from "react";
import { FilterChip } from "./FilterChip";
const meta: Meta<typeof FilterChip> = {
title: "Atoms/FilterChip",
component: FilterChip,
tags: ["autodocs"],
parameters: {
layout: "centered",
},
argTypes: {
size: {
control: "select",
options: ["sm", "md", "lg"],
},
},
};
export default meta;
type Story = StoryObj<typeof FilterChip>;
export const Default: Story = {
args: {
label: "Marketing",
},
};
export const Selected: Story = {
args: {
label: "Marketing",
selected: true,
},
};
export const Dismissible: Story = {
args: {
label: "Marketing",
selected: true,
dismissible: true,
},
};
export const Sizes: Story = {
render: () => (
<div className="flex items-center gap-4">
<FilterChip label="Small" size="sm" />
<FilterChip label="Medium" size="md" />
<FilterChip label="Large" size="lg" />
</div>
),
};
export const Disabled: Story = {
args: {
label: "Disabled",
disabled: true,
},
};
function FilterChipGroupDemo() {
const filters = [
"Marketing",
"Sales",
"Development",
"Design",
"Research",
"Analytics",
];
const [selected, setSelected] = useState<string[]>(["Marketing"]);
function handleToggle(filter: string) {
setSelected((prev) =>
prev.includes(filter)
? prev.filter((f) => f !== filter)
: [...prev, filter],
);
}
return (
<div className="flex flex-wrap gap-3">
{filters.map((filter) => (
<FilterChip
key={filter}
label={filter}
selected={selected.includes(filter)}
onClick={() => handleToggle(filter)}
/>
))}
</div>
);
}
export const FilterGroup: Story = {
render: () => <FilterChipGroupDemo />,
};
function SingleSelectDemo() {
const filters = ["All", "Featured", "Popular", "New"];
const [selected, setSelected] = useState("All");
return (
<div className="flex flex-wrap gap-3">
{filters.map((filter) => (
<FilterChip
key={filter}
label={filter}
selected={selected === filter}
onClick={() => setSelected(filter)}
/>
))}
</div>
);
}
export const SingleSelect: Story = {
render: () => <SingleSelectDemo />,
};
function DismissibleDemo() {
const [filters, setFilters] = useState([
"Marketing",
"Sales",
"Development",
]);
function handleDismiss(filter: string) {
setFilters((prev) => prev.filter((f) => f !== filter));
}
return (
<div className="flex flex-wrap gap-3">
{filters.map((filter) => (
<FilterChip
key={filter}
label={filter}
selected
dismissible
onDismiss={() => handleDismiss(filter)}
/>
))}
{filters.length === 0 && (
<span className="text-neutral-500">No filters selected</span>
)}
</div>
);
}
export const DismissibleGroup: Story = {
render: () => <DismissibleDemo />,
};

View File

@@ -0,0 +1,100 @@
"use client";
import { cn } from "@/lib/utils";
import { X } from "@phosphor-icons/react";
type FilterChipSize = "sm" | "md" | "lg";
interface FilterChipProps {
/** The label text displayed in the chip */
label: string;
/** Whether the chip is currently selected */
selected?: boolean;
/** Callback when the chip is clicked */
onClick?: () => void;
/** Whether to show a dismiss/remove button */
dismissible?: boolean;
/** Callback when the dismiss button is clicked */
onDismiss?: () => void;
/** Size variant of the chip */
size?: FilterChipSize;
/** Whether the chip is disabled */
disabled?: boolean;
/** Additional CSS classes */
className?: string;
}
const sizeStyles: Record<FilterChipSize, string> = {
sm: "px-3 py-1 text-sm gap-1.5",
md: "px-4 py-1.5 text-base gap-2",
lg: "px-6 py-2 text-lg gap-2.5 lg:text-xl lg:leading-9",
};
const iconSizes: Record<FilterChipSize, string> = {
sm: "h-3 w-3",
md: "h-4 w-4",
lg: "h-5 w-5",
};
/**
* A filter chip component for selecting/deselecting filter options.
* Supports single and multi-select patterns with proper accessibility.
*/
export function FilterChip({
label,
selected = false,
onClick,
dismissible = false,
onDismiss,
size = "md",
disabled = false,
className,
}: FilterChipProps) {
function handleDismiss(e: React.MouseEvent) {
e.stopPropagation();
onDismiss?.();
}
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-pressed={selected}
className={cn(
// Base styles
"inline-flex items-center justify-center rounded-full border font-medium transition-colors",
// Focus styles
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 dark:focus-visible:ring-neutral-50",
// Size styles
sizeStyles[size],
// State styles
selected
? "border-neutral-900 bg-neutral-100 text-neutral-800 dark:border-neutral-100 dark:bg-neutral-800 dark:text-neutral-200"
: "border-neutral-400 bg-transparent text-neutral-600 hover:bg-neutral-50 dark:border-neutral-500 dark:text-neutral-300 dark:hover:bg-neutral-800",
// Disabled styles
disabled && "pointer-events-none opacity-50",
className,
)}
>
<span>{label}</span>
{dismissible && selected && (
<span
role="button"
tabIndex={0}
onClick={handleDismiss}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleDismiss(e as unknown as React.MouseEvent);
}
}}
className="rounded-full p-0.5 hover:bg-neutral-200 dark:hover:bg-neutral-700"
aria-label={`Remove ${label} filter`}
>
<X className={iconSizes[size]} weight="bold" />
</span>
)}
</button>
);
}

View File

@@ -0,0 +1,72 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Separator } from "./Separator";
const meta: Meta<typeof Separator> = {
title: "Atoms/Separator",
component: Separator,
tags: ["autodocs"],
parameters: {
layout: "padded",
},
};
export default meta;
type Story = StoryObj<typeof Separator>;
export const Horizontal: Story = {
render: () => (
<div className="w-full max-w-md">
<p className="mb-4 text-neutral-700 dark:text-neutral-300">
Content above the separator
</p>
<Separator />
<p className="mt-4 text-neutral-700 dark:text-neutral-300">
Content below the separator
</p>
</div>
),
};
export const Vertical: Story = {
render: () => (
<div className="flex h-16 items-center gap-4">
<span className="text-neutral-700 dark:text-neutral-300">Left</span>
<Separator orientation="vertical" />
<span className="text-neutral-700 dark:text-neutral-300">Right</span>
</div>
),
};
export const WithCustomStyles: Story = {
render: () => (
<div className="w-full max-w-md space-y-4">
<Separator className="bg-violet-500" />
<Separator className="h-0.5 bg-gradient-to-r from-violet-500 to-blue-500" />
<Separator className="bg-neutral-400 dark:bg-neutral-600" />
</div>
),
};
export const InSection: Story = {
render: () => (
<div className="w-full max-w-md space-y-6">
<section>
<h2 className="mb-2 text-lg font-semibold text-neutral-900 dark:text-neutral-100">
Featured Agents
</h2>
<p className="text-neutral-600 dark:text-neutral-400">
Browse our collection of featured AI agents.
</p>
</section>
<Separator className="my-6" />
<section>
<h2 className="mb-2 text-lg font-semibold text-neutral-900 dark:text-neutral-100">
Top Creators
</h2>
<p className="text-neutral-600 dark:text-neutral-400">
Meet the creators behind the most popular agents.
</p>
</section>
</div>
),
};

View File

@@ -0,0 +1,43 @@
import { cn } from "@/lib/utils";
type SeparatorOrientation = "horizontal" | "vertical";
interface SeparatorProps {
/** The orientation of the separator */
orientation?: SeparatorOrientation;
/** Whether the separator is purely decorative (true) or represents a semantic boundary (false) */
decorative?: boolean;
/** Additional CSS classes */
className?: string;
}
/**
* A visual separator that divides content.
* Uses semantic `<hr>` for horizontal separators and a styled `<div>` for vertical.
*/
export function Separator({
orientation = "horizontal",
decorative = true,
className,
}: SeparatorProps) {
const baseStyles = "shrink-0 bg-neutral-200 dark:bg-neutral-800";
if (orientation === "horizontal") {
return (
<hr
className={cn(baseStyles, "h-px w-full border-0", className)}
aria-hidden={decorative}
role={decorative ? "none" : "separator"}
/>
);
}
return (
<div
className={cn(baseStyles, "h-full w-px", className)}
aria-hidden={decorative}
role={decorative ? "none" : "separator"}
aria-orientation="vertical"
/>
);
}

View File

@@ -0,0 +1,128 @@
import type { Meta, StoryObj } from "@storybook/react";
import { FadeIn } from "./FadeIn";
const meta: Meta<typeof FadeIn> = {
title: "Molecules/FadeIn",
component: FadeIn,
tags: ["autodocs"],
parameters: {
layout: "padded",
},
argTypes: {
direction: {
control: "select",
options: ["up", "down", "left", "right", "none"],
},
},
};
export default meta;
type Story = StoryObj<typeof FadeIn>;
const DemoCard = ({ title }: { title: string }) => (
<div className="rounded-xl bg-neutral-100 p-6 dark:bg-neutral-800">
<h3 className="mb-2 text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{title}
</h3>
<p className="text-neutral-600 dark:text-neutral-400">
This card fades in with a smooth animation.
</p>
</div>
);
export const Default: Story = {
args: {
direction: "up",
children: <DemoCard title="Fade Up" />,
},
};
export const FadeDown: Story = {
args: {
direction: "down",
children: <DemoCard title="Fade Down" />,
},
};
export const FadeLeft: Story = {
args: {
direction: "left",
children: <DemoCard title="Fade Left" />,
},
};
export const FadeRight: Story = {
args: {
direction: "right",
children: <DemoCard title="Fade Right" />,
},
};
export const FadeOnly: Story = {
args: {
direction: "none",
children: <DemoCard title="Fade Only (No Direction)" />,
},
};
export const WithDelay: Story = {
args: {
direction: "up",
delay: 0.5,
children: <DemoCard title="Delayed Fade (0.5s)" />,
},
};
export const SlowAnimation: Story = {
args: {
direction: "up",
duration: 1.5,
children: <DemoCard title="Slow Animation (1.5s)" />,
},
};
export const LargeDistance: Story = {
args: {
direction: "up",
distance: 60,
children: <DemoCard title="Large Distance (60px)" />,
},
};
export const MultipleElements: Story = {
render: () => (
<div className="space-y-4">
<FadeIn direction="up" delay={0}>
<DemoCard title="First Card" />
</FadeIn>
<FadeIn direction="up" delay={0.1}>
<DemoCard title="Second Card" />
</FadeIn>
<FadeIn direction="up" delay={0.2}>
<DemoCard title="Third Card" />
</FadeIn>
</div>
),
};
export const HeroExample: Story = {
render: () => (
<div className="text-center">
<FadeIn direction="down" delay={0}>
<h1 className="mb-4 text-4xl font-bold text-neutral-900 dark:text-neutral-100">
Welcome to the Marketplace
</h1>
</FadeIn>
<FadeIn direction="up" delay={0.2}>
<p className="mb-8 text-xl text-neutral-600 dark:text-neutral-400">
Discover AI agents built by the community
</p>
</FadeIn>
<FadeIn direction="up" delay={0.4}>
<button className="rounded-full bg-violet-600 px-8 py-3 text-white hover:bg-violet-700">
Get Started
</button>
</FadeIn>
</div>
),
};

View File

@@ -0,0 +1,109 @@
"use client";
import { cn } from "@/lib/utils";
import { motion, useReducedMotion, type Variants } from "framer-motion";
import { ReactNode } from "react";
type FadeDirection = "up" | "down" | "left" | "right" | "none";
interface FadeInProps {
/** Content to animate */
children: ReactNode;
/** Direction the content fades in from */
direction?: FadeDirection;
/** Distance to travel in pixels (only applies when direction is not "none") */
distance?: number;
/** Animation duration in seconds */
duration?: number;
/** Delay before animation starts in seconds */
delay?: number;
/** Whether to trigger animation when element enters viewport */
viewport?: boolean;
/** How much of element must be visible to trigger (0-1) */
viewportAmount?: number;
/** Whether animation should only trigger once */
once?: boolean;
/** Additional CSS classes */
className?: string;
/** HTML element to render as */
as?: keyof JSX.IntrinsicElements;
}
function getDirectionOffset(
direction: FadeDirection,
distance: number,
): { x: number; y: number } {
switch (direction) {
case "up":
return { x: 0, y: distance };
case "down":
return { x: 0, y: -distance };
case "left":
return { x: distance, y: 0 };
case "right":
return { x: -distance, y: 0 };
case "none":
default:
return { x: 0, y: 0 };
}
}
/**
* A fade-in animation wrapper component.
* Animates children with a fade effect and optional directional slide.
* Respects user's reduced motion preferences.
*/
export function FadeIn({
children,
direction = "up",
distance = 24,
duration = 0.5,
delay = 0,
viewport = true,
viewportAmount = 0.2,
once = true,
className,
as = "div",
}: FadeInProps) {
const shouldReduceMotion = useReducedMotion();
const offset = getDirectionOffset(direction, distance);
// If user prefers reduced motion, render without animation
if (shouldReduceMotion) {
const Component = as as keyof JSX.IntrinsicElements;
return <Component className={className}>{children}</Component>;
}
const variants: Variants = {
hidden: {
opacity: 0,
x: offset.x,
y: offset.y,
},
visible: {
opacity: 1,
x: 0,
y: 0,
transition: {
duration,
delay,
ease: [0.25, 0.1, 0.25, 1], // Custom easing for smooth feel
},
},
};
const MotionComponent = motion[as as keyof typeof motion] as typeof motion.div;
return (
<MotionComponent
className={cn(className)}
initial="hidden"
animate={viewport ? undefined : "visible"}
whileInView={viewport ? "visible" : undefined}
viewport={viewport ? { once, amount: viewportAmount } : undefined}
variants={variants}
>
{children}
</MotionComponent>
);
}

View File

@@ -0,0 +1,180 @@
import type { Meta, StoryObj } from "@storybook/react";
import { StaggeredList } from "./StaggeredList";
const meta: Meta<typeof StaggeredList> = {
title: "Molecules/StaggeredList",
component: StaggeredList,
tags: ["autodocs"],
parameters: {
layout: "padded",
},
argTypes: {
direction: {
control: "select",
options: ["up", "down", "left", "right", "none"],
},
},
};
export default meta;
type Story = StoryObj<typeof StaggeredList>;
const DemoCard = ({ title, index }: { title: string; index: number }) => (
<div className="rounded-xl bg-neutral-100 p-4 dark:bg-neutral-800">
<h3 className="mb-1 font-semibold text-neutral-900 dark:text-neutral-100">
{title}
</h3>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Card #{index + 1} with staggered animation
</p>
</div>
);
const items = ["First Item", "Second Item", "Third Item", "Fourth Item"];
export const Default: Story = {
args: {
direction: "up",
className: "space-y-4",
children: items.map((item, i) => <DemoCard key={i} title={item} index={i} />),
},
};
export const FadeDown: Story = {
args: {
direction: "down",
className: "space-y-4",
children: items.map((item, i) => <DemoCard key={i} title={item} index={i} />),
},
};
export const FadeLeft: Story = {
args: {
direction: "left",
className: "flex gap-4",
children: items.map((item, i) => <DemoCard key={i} title={item} index={i} />),
},
};
export const FadeRight: Story = {
args: {
direction: "right",
className: "flex gap-4",
children: items.map((item, i) => <DemoCard key={i} title={item} index={i} />),
},
};
export const FastStagger: Story = {
args: {
direction: "up",
staggerDelay: 0.05,
className: "space-y-4",
children: items.map((item, i) => <DemoCard key={i} title={item} index={i} />),
},
};
export const SlowStagger: Story = {
args: {
direction: "up",
staggerDelay: 0.3,
className: "space-y-4",
children: items.map((item, i) => <DemoCard key={i} title={item} index={i} />),
},
};
export const WithInitialDelay: Story = {
args: {
direction: "up",
initialDelay: 0.5,
className: "space-y-4",
children: items.map((item, i) => <DemoCard key={i} title={item} index={i} />),
},
};
export const GridLayout: Story = {
args: {
direction: "up",
staggerDelay: 0.08,
className: "grid grid-cols-2 gap-4 md:grid-cols-4",
children: [
...items,
"Fifth Item",
"Sixth Item",
"Seventh Item",
"Eighth Item",
].map((item, i) => <DemoCard key={i} title={item} index={i} />),
},
};
export const AgentCardsExample: Story = {
render: () => {
const agents = [
{ name: "SEO Optimizer", runs: 1234 },
{ name: "Content Writer", runs: 987 },
{ name: "Data Analyzer", runs: 756 },
{ name: "Code Reviewer", runs: 543 },
];
return (
<StaggeredList
direction="up"
staggerDelay={0.1}
className="grid grid-cols-2 gap-6 md:grid-cols-4"
>
{agents.map((agent, i) => (
<div
key={i}
className="rounded-2xl bg-white p-4 shadow-md dark:bg-neutral-900"
>
<div className="mb-3 aspect-video rounded-xl bg-gradient-to-br from-violet-500 to-blue-500" />
<h3 className="mb-1 font-semibold text-neutral-900 dark:text-neutral-100">
{agent.name}
</h3>
<p className="text-sm text-neutral-500">{agent.runs} runs</p>
</div>
))}
</StaggeredList>
);
},
};
export const CreatorCardsExample: Story = {
render: () => {
const creators = [
{ name: "Alice", agents: 12 },
{ name: "Bob", agents: 8 },
{ name: "Charlie", agents: 15 },
{ name: "Diana", agents: 6 },
];
const colors = [
"bg-violet-100 dark:bg-violet-900/30",
"bg-blue-100 dark:bg-blue-900/30",
"bg-green-100 dark:bg-green-900/30",
"bg-orange-100 dark:bg-orange-900/30",
];
return (
<StaggeredList
direction="up"
staggerDelay={0.12}
className="grid grid-cols-2 gap-6 md:grid-cols-4"
>
{creators.map((creator, i) => (
<div
key={i}
className={`rounded-2xl p-5 ${colors[i % colors.length]}`}
>
<div className="mb-3 h-12 w-12 rounded-full bg-neutral-300 dark:bg-neutral-700" />
<h3 className="mb-1 font-semibold text-neutral-900 dark:text-neutral-100">
{creator.name}
</h3>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
{creator.agents} agents
</p>
</div>
))}
</StaggeredList>
);
},
};

View File

@@ -0,0 +1,130 @@
"use client";
import { cn } from "@/lib/utils";
import { motion, useReducedMotion, type Variants } from "framer-motion";
import { ReactNode } from "react";
type StaggerDirection = "up" | "down" | "left" | "right" | "none";
interface StaggeredListProps {
/** Array of items to render with staggered animation */
children: ReactNode[];
/** Direction items animate from */
direction?: StaggerDirection;
/** Distance to travel in pixels */
distance?: number;
/** Base duration for each item's animation */
duration?: number;
/** Delay between each item's animation start */
staggerDelay?: number;
/** Initial delay before first item animates */
initialDelay?: number;
/** Whether to trigger animation when element enters viewport */
viewport?: boolean;
/** How much of container must be visible to trigger */
viewportAmount?: number;
/** Whether animation should only trigger once */
once?: boolean;
/** Additional CSS classes for the container */
className?: string;
/** Additional CSS classes for each item wrapper */
itemClassName?: string;
}
function getDirectionOffset(
direction: StaggerDirection,
distance: number,
): { x: number; y: number } {
switch (direction) {
case "up":
return { x: 0, y: distance };
case "down":
return { x: 0, y: -distance };
case "left":
return { x: distance, y: 0 };
case "right":
return { x: -distance, y: 0 };
case "none":
default:
return { x: 0, y: 0 };
}
}
/**
* Animates a list of children with staggered fade-in effects.
* Each child appears sequentially with a configurable delay.
* Respects user's reduced motion preferences.
*/
export function StaggeredList({
children,
direction = "up",
distance = 20,
duration = 0.4,
staggerDelay = 0.1,
initialDelay = 0,
viewport = true,
viewportAmount = 0.1,
once = true,
className,
itemClassName,
}: StaggeredListProps) {
const shouldReduceMotion = useReducedMotion();
const offset = getDirectionOffset(direction, distance);
// If user prefers reduced motion, render without animation
if (shouldReduceMotion) {
return (
<div className={className}>
{children.map((child, index) => (
<div key={index} className={itemClassName}>
{child}
</div>
))}
</div>
);
}
const containerVariants: Variants = {
hidden: {},
visible: {
transition: {
staggerChildren: staggerDelay,
delayChildren: initialDelay,
},
},
};
const itemVariants: Variants = {
hidden: {
opacity: 0,
x: offset.x,
y: offset.y,
},
visible: {
opacity: 1,
x: 0,
y: 0,
transition: {
duration,
ease: [0.25, 0.1, 0.25, 1],
},
},
};
return (
<motion.div
className={cn(className)}
initial="hidden"
animate={viewport ? undefined : "visible"}
whileInView={viewport ? "visible" : undefined}
viewport={viewport ? { once, amount: viewportAmount } : undefined}
variants={containerVariants}
>
{children.map((child, index) => (
<motion.div key={index} className={itemClassName} variants={itemVariants}>
{child}
</motion.div>
))}
</motion.div>
);
}

View File

@@ -218,61 +218,3 @@ test("shows error when deletion fails", async () => {
4. **Co-locate integration tests** - Keep `__tests__/` folder next to the component
5. **E2E is expensive** - Only for critical happy paths; prefer integration tests
6. **AI agents are good at writing integration tests** - Start with these when adding test coverage
---
## Testing 500 Server Errors
Orval auto-generates 422 validation error handlers, but 500 errors must be created manually. Use the helper:
```tsx
import { create500Handler } from "@/tests/integrations/helpers/create-500-handler";
test("handles server error", async () => {
server.use(create500Handler("get", "*/api/store/agents"));
render(<Component />);
expect(await screen.findByText("Failed to load")).toBeInTheDocument();
});
```
Options:
- `delayMs`: Add delay before response (for testing loading states)
- `body`: Custom error response body
---
## Testing Auth-Dependent Components
For components that behave differently based on login state:
```tsx
import {
mockAuthenticatedUser,
mockUnauthenticatedUser,
resetAuthState,
} from "@/tests/integrations/helpers/mock-supabase-auth";
describe("MyComponent", () => {
afterEach(() => {
resetAuthState();
});
test("shows feature when logged in", async () => {
mockAuthenticatedUser();
render(<MyComponent />);
expect(await screen.findByText("Premium Feature")).toBeInTheDocument();
});
test("hides feature when logged out", async () => {
mockUnauthenticatedUser();
render(<MyComponent />);
expect(screen.queryByText("Premium Feature")).not.toBeInTheDocument();
});
test("with custom user data", async () => {
mockAuthenticatedUser({ email: "custom@test.com" });
// ...
});
});
```

View File

@@ -1,31 +0,0 @@
import { http, HttpResponse, delay } from "msw";
type HttpMethod = "get" | "post" | "put" | "patch" | "delete";
interface Create500HandlerOptions {
delayMs?: number;
body?: Record<string, unknown>;
}
export function create500Handler(
method: HttpMethod,
url: string,
options?: Create500HandlerOptions,
) {
const { delayMs = 0, body } = options ?? {};
const responseBody = body ?? {
detail: "Internal Server Error",
};
return http[method](url, async () => {
if (delayMs > 0) {
await delay(delayMs);
}
return HttpResponse.json(responseBody, {
status: 500,
headers: { "Content-Type": "application/json" },
});
});
}

View File

@@ -1,42 +0,0 @@
import { createContext, useContext, ReactNode } from "react";
import { UserOnboarding } from "@/lib/autogpt-server-api";
import { PostV1CompleteOnboardingStepStep } from "@/app/api/__generated__/models/postV1CompleteOnboardingStepStep";
import type { LocalOnboardingStateUpdate } from "@/providers/onboarding/helpers";
const MockOnboardingContext = createContext<{
state: UserOnboarding | null;
updateState: (state: LocalOnboardingStateUpdate) => void;
step: number;
setStep: (step: number) => void;
completeStep: (step: PostV1CompleteOnboardingStepStep) => void;
}>({
state: null,
updateState: () => {},
step: 1,
setStep: () => {},
completeStep: () => {},
});
export function useOnboarding(
step?: number,
completeStep?: PostV1CompleteOnboardingStepStep,
) {
const context = useContext(MockOnboardingContext);
return context;
}
export function MockOnboardingProvider({ children }: { children: ReactNode }) {
return (
<MockOnboardingContext.Provider
value={{
state: null,
updateState: () => {},
step: 1,
setStep: () => {},
completeStep: () => {},
}}
>
{children}
</MockOnboardingContext.Provider>
);
}

View File

@@ -1,40 +0,0 @@
import type { User } from "@supabase/supabase-js";
import { useSupabaseStore } from "@/lib/supabase/hooks/useSupabaseStore";
export const mockUser: User = {
id: "test-user-id",
email: "test@example.com",
aud: "authenticated",
role: "authenticated",
created_at: new Date().toISOString(),
app_metadata: {},
user_metadata: {},
};
export function mockAuthenticatedUser(user: Partial<User> = {}): User {
const mergedUser = { ...mockUser, ...user };
useSupabaseStore.setState({
user: mergedUser,
isUserLoading: false,
hasLoadedUser: true,
});
return mergedUser;
}
export function mockUnauthenticatedUser(): void {
useSupabaseStore.setState({
user: null,
isUserLoading: false,
hasLoadedUser: true,
});
}
export function resetAuthState(): void {
useSupabaseStore.setState({
user: null,
isUserLoading: true,
hasLoadedUser: false,
});
}

View File

@@ -1,37 +0,0 @@
// Suppresses expected act(...) warnings from React Query and component async updates.
// These warnings are normal behavior with React Query and don't indicate test failures.
export function suppressReactQueryUpdateWarning() {
const originalError = console.error;
console.error = (...args: unknown[]) => {
const isActWarning = args.some(
(arg) =>
typeof arg === "string" &&
(arg.includes("not wrapped in act(...)") ||
arg.includes("An update to") && arg.includes("inside a test"))
);
if (isActWarning) {
const fullMessage = args
.map((arg) => String(arg))
.join("\n")
.toLowerCase();
const isReactQueryRelated =
fullMessage.includes("queryclientprovider") ||
fullMessage.includes("react query") ||
fullMessage.includes("@tanstack/react-query");
if (isReactQueryRelated || fullMessage.includes("testproviders")) {
return;
}
}
originalError(...args);
};
// Return cleanup function
return () => {
console.error = originalError;
};
}

View File

@@ -1,22 +1,14 @@
import { BackendAPIProvider } from "@/lib/autogpt-server-api/context";
import OnboardingProvider from "@/providers/onboarding/onboarding-provider";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, RenderOptions, act } from "@testing-library/react";
import { render, RenderOptions } from "@testing-library/react";
import { ReactElement, ReactNode } from "react";
import { MockOnboardingProvider, useOnboarding as mockUseOnboarding } from "./helpers/mock-onboarding-provider";
vi.mock("@/providers/onboarding/onboarding-provider", () => ({
useOnboarding: mockUseOnboarding,
default: vi.fn(),
}));
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
},
},
});
@@ -27,7 +19,7 @@ function TestProviders({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<BackendAPIProvider>
<MockOnboardingProvider>{children}</MockOnboardingProvider>
<OnboardingProvider>{children}</OnboardingProvider>
</BackendAPIProvider>
</QueryClientProvider>
);

View File

@@ -2,17 +2,11 @@ import { beforeAll, afterAll, afterEach } from "vitest";
import { server } from "@/mocks/mock-server";
import { mockNextjsModules } from "./setup-nextjs-mocks";
import { mockSupabaseRequest } from "./mock-supabase-request";
import "@testing-library/jest-dom";
import { suppressReactQueryUpdateWarning } from "./helpers/supress-react-query-update-warning";
beforeAll(() => {
mockNextjsModules();
mockSupabaseRequest();
const restoreConsoleError = suppressReactQueryUpdateWarning();
afterAll(() => {
restoreConsoleError();
});
mockSupabaseRequest(); // If you need user's data - please mock supabase actions in your specific test - it sends null user [It's only to avoid cookies() call]
return server.listen({ onUnhandledRequest: "error" });
});
afterEach(() => {server.resetHandlers()});
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

View File

@@ -9,7 +9,78 @@ function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
test.describe("Marketplace Agent Page - Cross-Page Flows", () => {
test.describe("Marketplace Agent Page - Basic Functionality", () => {
test("User can access agent page when logged out", async ({ page }) => {
const marketplacePage = new MarketplacePage(page);
await marketplacePage.goto(page);
await hasUrl(page, "/marketplace");
const firstStoreCard = await marketplacePage.getFirstTopAgent();
await firstStoreCard.click();
await page.waitForURL("**/marketplace/agent/**");
await matchesUrl(page, /\/marketplace\/agent\/.+/);
});
test("User can access agent page when logged in", async ({ page }) => {
const loginPage = new LoginPage(page);
const marketplacePage = new MarketplacePage(page);
await loginPage.goto();
const richUser = getTestUserWithLibraryAgents();
await loginPage.login(richUser.email, richUser.password);
await hasUrl(page, "/marketplace");
await marketplacePage.goto(page);
await hasUrl(page, "/marketplace");
const firstStoreCard = await marketplacePage.getFirstTopAgent();
await firstStoreCard.click();
await page.waitForURL("**/marketplace/agent/**");
await matchesUrl(page, /\/marketplace\/agent\/.+/);
});
test("Agent page details are visible", async ({ page }) => {
const { getId } = getSelectors(page);
const marketplacePage = new MarketplacePage(page);
await marketplacePage.goto(page);
const firstStoreCard = await marketplacePage.getFirstTopAgent();
await firstStoreCard.click();
await page.waitForURL("**/marketplace/agent/**");
const agentTitle = getId("agent-title");
await isVisible(agentTitle);
const agentDescription = getId("agent-description");
await isVisible(agentDescription);
const creatorInfo = getId("agent-creator");
await isVisible(creatorInfo);
});
test("Download button functionality works", async ({ page }) => {
const { getId, getText } = getSelectors(page);
const marketplacePage = new MarketplacePage(page);
await marketplacePage.goto(page);
const firstStoreCard = await marketplacePage.getFirstTopAgent();
await firstStoreCard.click();
await page.waitForURL("**/marketplace/agent/**");
const downloadButton = getId("agent-download-button");
await isVisible(downloadButton);
await downloadButton.click();
const downloadSuccessMessage = getText(
"Your agent has been successfully downloaded.",
);
await isVisible(downloadSuccessMessage);
});
test("Add to library button works and agent appears in library", async ({
page,
}) => {

View File

@@ -1,8 +1,64 @@
import { test } from "@playwright/test";
import { getTestUserWithLibraryAgents } from "./credentials";
import { LoginPage } from "./pages/login.page";
import { MarketplacePage } from "./pages/marketplace.page";
import { hasUrl, matchesUrl } from "./utils/assertion";
import { hasUrl, isVisible, matchesUrl } from "./utils/assertion";
import { getSelectors } from "./utils/selectors";
test.describe("Marketplace Creator Page Basic Functionality", () => {
test("User can access creator's page when logged out", async ({ page }) => {
const marketplacePage = new MarketplacePage(page);
await marketplacePage.goto(page);
await hasUrl(page, "/marketplace");
const firstCreatorProfile =
await marketplacePage.getFirstCreatorProfile(page);
await firstCreatorProfile.click();
await page.waitForURL("**/marketplace/creator/**");
await matchesUrl(page, /\/marketplace\/creator\/.+/);
});
test("User can access creator's page when logged in", async ({ page }) => {
const loginPage = new LoginPage(page);
const marketplacePage = new MarketplacePage(page);
await loginPage.goto();
const richUser = getTestUserWithLibraryAgents();
await loginPage.login(richUser.email, richUser.password);
await hasUrl(page, "/marketplace");
await marketplacePage.goto(page);
await hasUrl(page, "/marketplace");
const firstCreatorProfile =
await marketplacePage.getFirstCreatorProfile(page);
await firstCreatorProfile.click();
await page.waitForURL("**/marketplace/creator/**");
await matchesUrl(page, /\/marketplace\/creator\/.+/);
});
test("Creator page details are visible", async ({ page }) => {
const { getId } = getSelectors(page);
const marketplacePage = new MarketplacePage(page);
await marketplacePage.goto(page);
await hasUrl(page, "/marketplace");
const firstCreatorProfile =
await marketplacePage.getFirstCreatorProfile(page);
await firstCreatorProfile.click();
await page.waitForURL("**/marketplace/creator/**");
const creatorTitle = getId("creator-title");
await isVisible(creatorTitle);
const creatorDescription = getId("creator-description");
await isVisible(creatorDescription);
});
test.describe("Marketplace Creator Page Cross-Page Flows", () => {
test("Agents in agent by sections navigation works", async ({ page }) => {
const marketplacePage = new MarketplacePage(page);

View File

@@ -1,8 +1,74 @@
import { expect, test } from "@playwright/test";
import { getTestUserWithLibraryAgents } from "./credentials";
import { LoginPage } from "./pages/login.page";
import { MarketplacePage } from "./pages/marketplace.page";
import { isVisible, matchesUrl } from "./utils/assertion";
import { hasMinCount, hasUrl, isVisible, matchesUrl } from "./utils/assertion";
// Marketplace tests for store agent search functionality
test.describe("Marketplace Basic Functionality", () => {
test("User can access marketplace page when logged out", async ({ page }) => {
const marketplacePage = new MarketplacePage(page);
await marketplacePage.goto(page);
await hasUrl(page, "/marketplace");
const marketplaceTitle = await marketplacePage.getMarketplaceTitle(page);
await isVisible(marketplaceTitle);
console.log(
"User can access marketplace page when logged out test passed ✅",
);
});
test("User can access marketplace page when logged in", async ({ page }) => {
const loginPage = new LoginPage(page);
const marketplacePage = new MarketplacePage(page);
await loginPage.goto();
const richUser = getTestUserWithLibraryAgents();
await loginPage.login(richUser.email, richUser.password);
await hasUrl(page, "/marketplace");
await marketplacePage.goto(page);
await hasUrl(page, "/marketplace");
const marketplaceTitle = await marketplacePage.getMarketplaceTitle(page);
await isVisible(marketplaceTitle);
console.log(
"User can access marketplace page when logged in test passed ✅",
);
});
test("Featured agents, top agents, and featured creators are visible", async ({
page,
}) => {
const marketplacePage = new MarketplacePage(page);
await marketplacePage.goto(page);
const featuredAgentsSection =
await marketplacePage.getFeaturedAgentsSection(page);
await isVisible(featuredAgentsSection);
const featuredAgentCards =
await marketplacePage.getFeaturedAgentCards(page);
await hasMinCount(featuredAgentCards, 1);
const topAgentsSection = await marketplacePage.getTopAgentsSection(page);
await isVisible(topAgentsSection);
const topAgentCards = await marketplacePage.getTopAgentCards(page);
await hasMinCount(topAgentCards, 1);
const featuredCreatorsSection =
await marketplacePage.getFeaturedCreatorsSection(page);
await isVisible(featuredCreatorsSection);
const creatorProfiles = await marketplacePage.getCreatorProfiles(page);
await hasMinCount(creatorProfiles, 1);
console.log(
"Featured agents, top agents, and featured creators are visible test passed ✅",
);
});
test.describe("Marketplace Navigation", () => {
test("Can navigate and interact with marketplace elements", async ({
page,
}) => {
@@ -30,7 +96,7 @@ test.describe("Marketplace Navigation", () => {
await matchesUrl(page, /\/marketplace\/creator\/.+/);
console.log(
"Can navigate and interact with marketplace elements test passed",
"Can navigate and interact with marketplace elements test passed",
);
});
@@ -55,6 +121,32 @@ test.describe("Marketplace Navigation", () => {
const results = await marketplacePage.getSearchResultsCount(page);
expect(results).toBeGreaterThan(0);
console.log("Complete search flow works correctly test passed");
console.log("Complete search flow works correctly test passed");
});
// We need to add a test search with filters, but the current business logic for filters doesn't work as expected. We'll add it once we modify that.
});
test.describe("Marketplace Edge Cases", () => {
test("Search for non-existent item shows no results", async ({ page }) => {
const marketplacePage = new MarketplacePage(page);
await marketplacePage.goto(page);
await marketplacePage.searchAndNavigate("xyznonexistentitemxyz123", page);
await marketplacePage.waitForSearchResults();
await matchesUrl(page, /\/marketplace\/search\?searchTerm=/);
const resultsHeading = page.getByText("Results for:");
await isVisible(resultsHeading);
const searchTerm = page.getByText("xyznonexistentitemxyz123");
await isVisible(searchTerm);
const results = await marketplacePage.getSearchResultsCount(page);
expect(results).toBe(0);
console.log("Search for non-existent item shows no results test passed ✅");
});
});

View File

@@ -14,7 +14,6 @@
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"types": ["vitest/globals", "@testing-library/jest-dom/vitest"],
"paths": {
"@/*": ["./src/*"]
}

View File

@@ -8,6 +8,5 @@ export default defineConfig({
environment: "happy-dom",
include: ["src/**/*.test.tsx"],
setupFiles: ["./src/tests/integrations/vitest.setup.tsx"],
globals: true,
},
});