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
136 changes: 57 additions & 79 deletions __tests__/roadmap-page.test.tsx
Original file line number Diff line number Diff line change
@@ -1,104 +1,82 @@
/** @jest-environment jsdom */

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

type LinkProps = {
href: string;
children?: React.ReactNode;
} & React.AnchorHTMLAttributes<HTMLAnchorElement>;
const mockItems = [
{
id: "workspace-profiles",
title: "Workspace Profiles",
description: "Save and switch between project-specific agent configurations.",
status: "planned",
votes: 42,
},
{
id: "roadmap-voting-api",
title: "Roadmap Voting API",
description: "Public API support for roadmap voting and analytics.",
status: "in-progress",
votes: 74,
},
{
id: "public-skill-registry",
title: "Public Skill Registry",
description: "Searchable index of verified skills and metadata.",
status: "completed",
votes: 96,
},
];

jest.mock("next/link", () => {
const LinkMock = ({ href, children, ...props }: LinkProps) => (
<a href={href} {...props}>
{children}
</a>
);
LinkMock.displayName = "Link";
return LinkMock;
});
describe("Roadmap Page", () => {
beforeEach(() => {
global.fetch = jest.fn((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);

jest.mock("next/navigation", () => ({
usePathname: jest.fn(() => "/roadmap"),
}));
if (url === "/api/roadmap" && (!init || init.method === undefined)) {
return Promise.resolve({
ok: true,
json: async () => ({ items: mockItems, total: mockItems.length }),
} as Response);
}

describe("Roadmap Page", () => {
it("renders the roadmap page", () => {
const { container } = render(<RoadmapPage />);
expect(container).toBeInTheDocument();
});
if (url === "/api/roadmap/vote" && init?.method === "POST") {
return Promise.resolve({
ok: true,
json: async () => ({ id: "workspace-profiles", votes: 43 }),
} as Response);
}

it("displays the page title", () => {
render(<RoadmapPage />);
expect(screen.getByText("Product Roadmap")).toBeInTheDocument();
return Promise.resolve({ ok: false } as Response);
}) as jest.Mock;
});

it("displays the page description", () => {
render(<RoadmapPage />);
expect(
screen.getByText(/See what we're building/i)
).toBeInTheDocument();
afterEach(() => {
jest.resetAllMocks();
});

it("displays category filter buttons", () => {
it("renders roadmap columns from API data", async () => {
render(<RoadmapPage />);
expect(screen.getByRole("button", { name: "All" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Platform" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Skills" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Community" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "API" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Enterprise" })).toBeInTheDocument();
});

it("displays sort dropdown", () => {
render(<RoadmapPage />);
expect(screen.getByText("Sort by:")).toBeInTheDocument();
const select = screen.getByRole("combobox");
expect(select).toBeInTheDocument();
});
await waitFor(() => {
expect(screen.getByText("Workspace Profiles")).toBeInTheDocument();
});

it("displays all status columns", () => {
render(<RoadmapPage />);
expect(screen.getByText("Planned")).toBeInTheDocument();
expect(screen.getByText("In Progress")).toBeInTheDocument();
expect(screen.getByText("Shipped")).toBeInTheDocument();
expect(screen.getByText("Considering")).toBeInTheDocument();
expect(screen.getByText("Completed")).toBeInTheDocument();
});

it("displays roadmap items", () => {
it("votes on planned items", async () => {
render(<RoadmapPage />);
// Check for at least one item from the seed data
expect(screen.getByText("Real-time Agent Monitoring")).toBeInTheDocument();
});

it("allows filtering by category", () => {
render(<RoadmapPage />);
const platformButton = screen.getByRole("button", { name: "Platform" });
fireEvent.click(platformButton);
expect(screen.getByText("Real-time Agent Monitoring")).toBeInTheDocument();
});
await waitFor(() => {
expect(screen.getByText("Workspace Profiles")).toBeInTheDocument();
});

it("allows sorting items", () => {
render(<RoadmapPage />);
const select = screen.getByRole("combobox");
fireEvent.change(select, { target: { value: "newest" } });
expect(select).toHaveValue("newest");
});
fireEvent.click(screen.getByRole("button", { name: "Vote" }));

it("displays upvote and comment counts", () => {
render(<RoadmapPage />);
// Check that vote/comment icons and counts are rendered
const cards = screen.getAllByRole("link");
expect(cards.length).toBeGreaterThan(0);
});

it("items link to detail pages", () => {
render(<RoadmapPage />);
const links = screen.getAllByRole("link");
const roadmapLink = links.find((link) =>
link.getAttribute("href")?.startsWith("/roadmap/")
);
expect(roadmapLink).toBeTruthy();
await waitFor(() => {
expect(screen.getByText("43 votes")).toBeInTheDocument();
});
});
});
107 changes: 107 additions & 0 deletions data/roadmap.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
[
{
"id": "workspace-profiles",
"title": "Workspace Profiles",
"description": "Save and switch between project-specific agent configurations.",
"status": "planned",
"votes": 42
},
{
"id": "smart-context-packs",
"title": "Smart Context Packs",
"description": "Auto-load relevant docs and memories based on the current task.",
"status": "planned",
"votes": 57
},
{
"id": "voice-story-mode",
"title": "Voice Story Mode",
"description": "One-click narrated summaries and updates using built-in TTS.",
"status": "planned",
"votes": 33
},
{
"id": "team-handoffs",
"title": "Team Handoffs",
"description": "Structured lane handoff notes between agents and teammates.",
"status": "planned",
"votes": 61
},
{
"id": "prompt-diff-view",
"title": "Prompt Diff View",
"description": "Compare instruction changes over time with inline highlights.",
"status": "planned",
"votes": 25
},
{
"id": "roadmap-voting-api",
"title": "Roadmap Voting API",
"description": "Public API support for roadmap voting and analytics.",
"status": "in-progress",
"votes": 74
},
{
"id": "agent-observability",
"title": "Agent Observability",
"description": "Latency, token, and failure dashboards for agent runs.",
"status": "in-progress",
"votes": 89
},
{
"id": "marketplace-reviews",
"title": "Marketplace Reviews",
"description": "Community ratings and written reviews for shared artifacts.",
"status": "in-progress",
"votes": 52
},
{
"id": "premium-entitlements-v2",
"title": "Premium Entitlements v2",
"description": "Granular usage limits and team-level premium features.",
"status": "in-progress",
"votes": 48
},
{
"id": "changelog-digests",
"title": "Changelog Digests",
"description": "Weekly digest emails and API feed summaries.",
"status": "in-progress",
"votes": 40
},
{
"id": "public-skill-registry",
"title": "Public Skill Registry",
"description": "Searchable index of verified skills and metadata.",
"status": "completed",
"votes": 96
},
{
"id": "agent-verification-badges",
"title": "Agent Verification Badges",
"description": "Visual trust indicators for validated agents and MCP servers.",
"status": "completed",
"votes": 87
},
{
"id": "digest-api",
"title": "Digest API",
"description": "Programmatic digest endpoint for external clients.",
"status": "completed",
"votes": 65
},
{
"id": "artifacts-rss-feeds",
"title": "Artifacts RSS Feeds",
"description": "RSS/JSON feeds for artifact publishing and updates.",
"status": "completed",
"votes": 58
},
{
"id": "search-quota-controls",
"title": "Search Quota Controls",
"description": "Per-user search quota tracking and upgrade prompts.",
"status": "completed",
"votes": 71
}
]
26 changes: 26 additions & 0 deletions src/app/api/roadmap/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from "next/server";
import { getRoadmapItems, isRoadmapStatus } from "@/lib/server/roadmapStore";

export async function GET(request: NextRequest) {
try {
const items = await getRoadmapItems();
const status = request.nextUrl.searchParams.get("status");

if (status && !isRoadmapStatus(status)) {
return NextResponse.json(
{ error: "Invalid status. Use planned, in-progress, or completed." },
{ status: 400 }
);
}

const filteredItems = status ? items.filter((item) => item.status === status) : items;

return NextResponse.json(
{ items: filteredItems, total: filteredItems.length },
{ headers: { "Cache-Control": "no-store" } }
);
} catch (error) {
console.error("Failed to load roadmap items", error);
return NextResponse.json({ error: "Failed to load roadmap" }, { status: 500 });
}
}
45 changes: 45 additions & 0 deletions src/app/api/roadmap/vote/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from "next/server";
import { checkRateLimit, getClientIp } from "@/lib/requestLimits";
import { incrementRoadmapVote } from "@/lib/server/roadmapStore";

const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000;

export async function POST(request: NextRequest) {
try {
const body = (await request.json()) as { itemId?: unknown };
const itemId = typeof body.itemId === "string" ? body.itemId.trim() : "";

if (!itemId) {
return NextResponse.json({ error: "itemId is required" }, { status: 400 });
}

const ip = getClientIp(request);
const limited = checkRateLimit(`roadmap-vote:${ip}:${itemId}`, {
windowMs: RATE_LIMIT_WINDOW_MS,
max: 1,
});

if (!limited.ok) {
return NextResponse.json(
{ error: "Vote limit reached for this item. Try again later." },
{
status: 429,
headers: {
"Retry-After": String(limited.retryAfterSec),
},
}
);
}

const vote = await incrementRoadmapVote(itemId);

if (!vote) {
return NextResponse.json({ error: "Roadmap item not found" }, { status: 404 });
}

return NextResponse.json(vote);
} catch (error) {
console.error("Failed to vote on roadmap item", error);
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
}
}
22 changes: 21 additions & 1 deletion src/app/roadmap/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,27 @@ import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import roadmapData from "@/data/roadmap.json";
import type { RoadmapItem, RoadmapStatus, RoadmapCategory } from "../page";

type RoadmapStatus = "Planned" | "In Progress" | "Shipped" | "Considering";
type RoadmapCategory = "Platform" | "Skills" | "Community" | "API" | "Enterprise";

type RoadmapItem = {
id: string;
title: string;
description: string;
fullDescription: string;
category: RoadmapCategory;
status: RoadmapStatus;
upvotes: number;
commentCount: number;
createdAt: string;
updatedAt: string;
timeline: Array<{
date: string;
status: RoadmapStatus;
note: string;
}>;
};

const categoryColors: Record<RoadmapCategory, string> = {
Platform: "bg-cyan/10 text-cyan border-cyan/30",
Expand Down
Loading
Loading