Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 61 additions & 54 deletions __tests__/faq-page.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/** @jest-environment jsdom */

import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import FAQPage from "../src/app/faq/page";

type LinkProps = {
Expand All @@ -19,7 +19,38 @@ jest.mock("next/link", () => {
return LinkMock;
});

const mockFaqResponse = {
faqs: [
{
id: "faq-getting-started-1",
category: "getting-started",
question: "What is forAgents.dev in one sentence?",
answer:
"forAgents.dev is an agent-first directory of reusable skills, MCP servers, templates, and guides.",
},
{
id: "faq-skills-1",
category: "skills",
question: "What is a skill on forAgents.dev?",
answer: "A skill is a focused capability package with docs and install instructions.",
},
],
total: 15,
categories: ["getting-started", "skills", "mcp", "pricing", "agents"],
};

describe("FAQ Page", () => {
beforeEach(() => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => mockFaqResponse,
}) as jest.Mock;
});

afterEach(() => {
jest.clearAllMocks();
});

it("renders the FAQ page", () => {
const { container } = render(<FAQPage />);
expect(container).toBeInTheDocument();
Expand All @@ -32,84 +63,60 @@ describe("FAQ Page", () => {

it("displays the search bar", () => {
render(<FAQPage />);
const searchInput = screen.getByPlaceholderText("Search questions...");
expect(searchInput).toBeInTheDocument();
expect(screen.getByPlaceholderText("Search questions...")).toBeInTheDocument();
});

it("displays all category sections", () => {
it("shows category tabs", async () => {
render(<FAQPage />);
expect(screen.getByText("General")).toBeInTheDocument();
expect(screen.getByText("Getting Started")).toBeInTheDocument();
expect(screen.getByText("Skills & Kits")).toBeInTheDocument();
expect(screen.getByText("Billing & Pricing")).toBeInTheDocument();
expect(screen.getByText("Security")).toBeInTheDocument();
expect(screen.getByText("API")).toBeInTheDocument();
expect(screen.getByText("Enterprise")).toBeInTheDocument();
});

it("displays question count for each category", () => {
render(<FAQPage />);
// Each category should have (4) next to it
const categoryHeaders = screen.getAllByText(/\(4\)/);
expect(categoryHeaders.length).toBeGreaterThan(0);
expect(await screen.findByRole("button", { name: "All" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Getting Started" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Skills" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "MCP" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Pricing" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Agents" })).toBeInTheDocument();
});

it("filters questions based on search input", () => {
it("displays result count after searching", async () => {
render(<FAQPage />);
const searchInput = screen.getByPlaceholderText("Search questions...");

fireEvent.change(searchInput, { target: { value: "API" } });

// Should show result count
expect(screen.getByText(/result\(s\) found/)).toBeInTheDocument();

fireEvent.change(searchInput, { target: { value: "skill" } });

await waitFor(() => {
expect(screen.getByText(/result\(s\) found/)).toBeInTheDocument();
});
});

it("displays 'Still need help?' CTA section", () => {
it("includes CTA links", () => {
render(<FAQPage />);
expect(screen.getByText("Still need help?")).toBeInTheDocument();
expect(screen.getByText("Join Community")).toBeInTheDocument();
expect(screen.getByText("Contact Support")).toBeInTheDocument();
});

it("includes link to community page", () => {
render(<FAQPage />);
const communityLink = screen.getByText("Join Community").closest("a");
expect(communityLink).toHaveAttribute("href", "/community");
});

it("includes link to support email", () => {
render(<FAQPage />);
const supportLink = screen.getByText("Contact Support").closest("a");
expect(supportLink).toHaveAttribute("href", "mailto:support@foragents.dev");
});

it("displays thumbs up/down feedback buttons when accordion is opened", () => {
it("displays thumbs up/down feedback buttons when accordion is opened", async () => {
render(<FAQPage />);

// Find and click on the first accordion trigger to open it
const firstQuestion = screen.getByText("What is forAgents.dev?");

const firstQuestion = await screen.findByText("What is forAgents.dev in one sentence?");
fireEvent.click(firstQuestion);

// Now the feedback buttons should be visible
const thumbsUpButtons = screen.getAllByLabelText("Thumbs up");
const thumbsDownButtons = screen.getAllByLabelText("Thumbs down");

// Should have at least one of each button
expect(thumbsUpButtons.length).toBeGreaterThan(0);
expect(thumbsDownButtons.length).toBeGreaterThan(0);
});

it("shows no results message when search returns empty", () => {
render(<FAQPage />);
const searchInput = screen.getByPlaceholderText("Search questions...");

fireEvent.change(searchInput, { target: { value: "xyznonexistentquery123" } });

expect(screen.getByText(/No questions found matching/)).toBeInTheDocument();
expect(screen.getAllByLabelText("Thumbs up").length).toBeGreaterThan(0);
expect(screen.getAllByLabelText("Thumbs down").length).toBeGreaterThan(0);
});

it("displays total question count in hero section", () => {
it("calls faq api", async () => {
render(<FAQPage />);
expect(screen.getByText(/28 questions across/)).toBeInTheDocument();

await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
"/api/faq",
expect.objectContaining({ method: "GET", cache: "no-store" })
);
});
});
});
94 changes: 94 additions & 0 deletions data/faq.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
{
"faqs": [
{
"id": "faq-getting-started-1",
"category": "getting-started",
"question": "What is forAgents.dev in one sentence?",
"answer": "forAgents.dev is an agent-first directory of reusable skills, MCP servers, templates, and guides that help AI agents become useful faster."
},
{
"id": "faq-getting-started-2",
"category": "getting-started",
"question": "How do I bootstrap a new agent with forAgents.dev?",
"answer": "Start with /bootstrap, copy the bootstrap prompt or JSON, and load it into your agent so it understands project structure, skills, and best practices."
},
{
"id": "faq-getting-started-3",
"category": "getting-started",
"question": "Do I need an account to use forAgents.dev?",
"answer": "No account is required for public browsing and basic usage. Accounts are useful for submissions, personalization, and premium features."
},
{
"id": "faq-skills-1",
"category": "skills",
"question": "What is a skill on forAgents.dev?",
"answer": "A skill is a focused capability package with docs and install instructions that an agent can adopt, such as web scraping, summarization, or workflow orchestration."
},
{
"id": "faq-skills-2",
"category": "skills",
"question": "How do I know if a skill is safe to install?",
"answer": "Review verification signals, source links, docs quality, and community feedback before installing. Prefer transparent, well-maintained skills with clear permissions."
},
{
"id": "faq-skills-3",
"category": "skills",
"question": "Can I publish my own skill?",
"answer": "Yes. Use the submission flow, include clear documentation, and provide reproducible install steps so agents and developers can evaluate your skill quickly."
},
{
"id": "faq-mcp-1",
"category": "mcp",
"question": "Does forAgents.dev support MCP servers?",
"answer": "Yes. MCP is a first-class concept on forAgents.dev, including server discovery, integration details, and health metadata where available."
},
{
"id": "faq-mcp-2",
"category": "mcp",
"question": "What MCP details are shown on listings?",
"answer": "Listings can include install commands, transport type, required environment variables, and operational notes so agents can integrate with fewer failures."
},
{
"id": "faq-mcp-3",
"category": "mcp",
"question": "How can I troubleshoot MCP integration issues?",
"answer": "Check server logs, validate required env vars, verify transport configuration, and test with minimal prompts before scaling to production workflows."
},
{
"id": "faq-pricing-1",
"category": "pricing",
"question": "Is forAgents.dev free to use?",
"answer": "Core discovery and many resources are available for free. Some advanced features and premium workflows are part of paid plans."
},
{
"id": "faq-pricing-2",
"category": "pricing",
"question": "What does premium unlock?",
"answer": "Premium can include expanded discovery tools, deeper analytics, higher limits, and priority experiences for active builders and teams."
},
{
"id": "faq-pricing-3",
"category": "pricing",
"question": "Do listed tools have separate costs?",
"answer": "Often yes. forAgents.dev indexes tools, skills, and MCP servers that may have their own pricing, so always review each listing's licensing details."
},
{
"id": "faq-agents-1",
"category": "agents",
"question": "Which agent frameworks work with forAgents.dev?",
"answer": "forAgents.dev is framework-agnostic and works with many agent stacks as long as they can consume structured docs, APIs, or MCP integrations."
},
{
"id": "faq-agents-2",
"category": "agents",
"question": "Can teams standardize agent behavior using forAgents.dev?",
"answer": "Yes. Teams can use shared templates, preferred skill sets, and onboarding docs to make agents more consistent across projects and environments."
},
{
"id": "faq-agents-3",
"category": "agents",
"question": "How often is content updated?",
"answer": "The catalog evolves continuously as new skills, MCP servers, and guides are submitted and curated by the community and maintainers."
}
]
}
90 changes: 90 additions & 0 deletions src/app/api/faq/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { NextRequest, NextResponse } from "next/server";
import { promises as fs } from "node:fs";
import path from "node:path";

const FAQ_CATEGORIES = ["getting-started", "skills", "mcp", "pricing", "agents"] as const;
type FaqCategory = (typeof FAQ_CATEGORIES)[number];

type FaqItem = {
id: string;
category: FaqCategory;
question: string;
answer: string;
};

type FaqFile = {
faqs: FaqItem[];
};

async function readFaqFile(): Promise<FaqItem[]> {
const faqPath = path.join(process.cwd(), "data", "faq.json");
const raw = await fs.readFile(faqPath, "utf8");
const parsed = JSON.parse(raw) as FaqFile;

if (!parsed || !Array.isArray(parsed.faqs)) {
return [];
}

return parsed.faqs.filter(
(faq): faq is FaqItem =>
typeof faq?.id === "string" &&
typeof faq?.question === "string" &&
typeof faq?.answer === "string" &&
typeof faq?.category === "string" &&
FAQ_CATEGORIES.includes(faq.category as FaqCategory)
);
}

export async function GET(request: NextRequest) {
try {
const allFaqs = await readFaqFile();

const search = request.nextUrl.searchParams.get("search")?.trim().toLowerCase() ?? "";
const categoryParam = request.nextUrl.searchParams.get("category")?.trim().toLowerCase() ?? "";

if (categoryParam && !FAQ_CATEGORIES.includes(categoryParam as FaqCategory)) {
return NextResponse.json(
{
error: "Invalid category",
allowed: FAQ_CATEGORIES,
},
{ status: 400 }
);
}

let filtered = allFaqs;

if (categoryParam) {
filtered = filtered.filter((faq) => faq.category === categoryParam);
}

if (search) {
filtered = filtered.filter((faq) => {
const haystack = `${faq.question} ${faq.answer}`.toLowerCase();
return haystack.includes(search);
});
}

return NextResponse.json(
{
faqs: filtered,
total: filtered.length,
categories: FAQ_CATEGORIES,
},
{
headers: {
"Cache-Control": "no-store",
},
}
);
} catch {
return NextResponse.json(
{
faqs: [],
total: 0,
categories: FAQ_CATEGORIES,
},
{ status: 200 }
);
}
}
Loading
Loading