diff --git a/e2e/auth.spec.ts b/e2e/auth.spec.ts new file mode 100644 index 0000000..d5d52cd --- /dev/null +++ b/e2e/auth.spec.ts @@ -0,0 +1,47 @@ +import { expect, test } from "@playwright/test"; + +test.describe("Authentication", () => { + test("login page renders at /login", async ({ page }) => { + await page.goto("/login"); + await page.waitForLoadState("networkidle"); + + // Should show the Sign In heading + await expect(page.getByRole("heading", { name: "Sign In" })).toBeVisible({ + timeout: 5000, + }); + + // Should have username and password inputs + await expect(page.locator("#username")).toBeVisible({ timeout: 5000 }); + await expect(page.locator("#password")).toBeVisible({ timeout: 5000 }); + }); + + test("login with valid credentials succeeds and redirects away from /login", async ({ + page, + }) => { + await page.goto("/login"); + await page.waitForLoadState("networkidle"); + + await page.locator("#username").fill("admin"); + await page.locator("#password").fill("password123"); + await page.getByRole("button", { name: "Sign In" }).click(); + + // After successful login, should redirect to / (dashboard) + await page.waitForURL("/", { timeout: 5000 }); + await expect(page).not.toHaveURL(/\/login/); + await expect(page.getByText("GearBox")).toBeVisible({ timeout: 5000 }); + }); + + test("login with wrong password shows error", async ({ page }) => { + await page.goto("/login"); + await page.waitForLoadState("networkidle"); + + await page.locator("#username").fill("admin"); + await page.locator("#password").fill("wrongpassword"); + await page.getByRole("button", { name: "Sign In" }).click(); + + // Should stay on the login page and show an error message + await expect(page).toHaveURL(/\/login/, { timeout: 5000 }); + // The error paragraph should be visible (login.tsx renders

{error}

) + await expect(page.locator(".text-red-600")).toBeVisible({ timeout: 5000 }); + }); +}); diff --git a/e2e/error-handling.spec.ts b/e2e/error-handling.spec.ts new file mode 100644 index 0000000..4dda58f --- /dev/null +++ b/e2e/error-handling.spec.ts @@ -0,0 +1,59 @@ +import { expect, test } from "@playwright/test"; + +test.describe("Error handling — non-existent routes", () => { + test("non-existent thread does not white-screen", async ({ page }) => { + await page.goto("/threads/99999"); + + // React Query retries failed requests (default 3 times with backoff) before + // setting isError=true. Wait for the page to leave loading state and show content. + // The thread detail page renders "Thread not found" + "Back to planning" link on error. + await expect(page.getByText("Back to planning")).toBeVisible({ + timeout: 30000, + }); + await expect(page.getByText("Thread not found")).toBeVisible({ + timeout: 5000, + }); + }); + + test("non-existent setup does not white-screen", async ({ page }) => { + await page.goto("/setups/99999"); + await page.waitForLoadState("networkidle"); + + // Setup detail shows "Setup not found." when data is null (no retry — isLoading=false, data=undefined) + // The setup query resolves with undefined rather than throwing for missing items. + // Check that the page has content (not a blank screen). + const body = page.locator("body"); + await expect(body).not.toBeEmpty(); + + // Navigation header should be visible (app did not crash) + await expect(page.getByText("GearBox")).toBeVisible({ timeout: 5000 }); + + // Wait for setup data to load; it will show "Setup not found." when done + await expect(page.getByText("Setup not found.")).toBeVisible({ + timeout: 30000, + }); + }); + + test("app recovers after bad route — dashboard loads fine", async ({ + page, + }) => { + // Navigate to a non-existent thread first + await page.goto("/threads/99999"); + + // The page should render without crashing — wait for the error state + // (React Query retries before showing isError state, so use a long timeout) + await expect(page.getByText("Back to planning")).toBeVisible({ + timeout: 30000, + }); + + // Now navigate to the dashboard + await page.goto("/"); + await page.waitForLoadState("networkidle"); + + // Dashboard should load normally + await expect(page.getByText("GearBox")).toBeVisible({ timeout: 5000 }); + await expect(page.getByRole("heading", { name: "Collection" })).toBeVisible( + { timeout: 5000 }, + ); + }); +}); diff --git a/e2e/threads.spec.ts b/e2e/threads.spec.ts new file mode 100644 index 0000000..a75b041 --- /dev/null +++ b/e2e/threads.spec.ts @@ -0,0 +1,113 @@ +import { expect, test } from "@playwright/test"; + +test.describe("Thread detail page", () => { + test("loads with candidates visible", async ({ page }) => { + await page.goto("/collection?tab=planning"); + await page.waitForLoadState("networkidle"); + + // Click the "New Backpack" thread card + await page.getByText("New Backpack").click(); + await page.waitForLoadState("networkidle"); + + // Thread detail page should show the thread name + await expect( + page.getByRole("heading", { name: "New Backpack" }), + ).toBeVisible({ timeout: 5000 }); + + // Candidates should be visible + await expect(page.getByText("ULA Circuit")).toBeVisible({ timeout: 5000 }); + await expect(page.getByText("Gossamer Gear Mariposa")).toBeVisible({ + timeout: 5000, + }); + await expect(page.getByText("Granite Gear Crown2 38")).toBeVisible({ + timeout: 5000, + }); + }); + + test("rank badges are visible for top 3 candidates", async ({ page }) => { + await page.goto("/collection?tab=planning"); + await page.waitForLoadState("networkidle"); + + await page.getByText("New Backpack").click(); + await page.waitForLoadState("networkidle"); + + // Rank badges are medal icons — the component renders LucideIcon with name="medal" + // for ranks 1, 2, 3. We can verify via SVG elements or by checking the page has + // the expected number of medal icons (one per top candidate). + // The list view is default, which renders CandidateListItem with RankBadge. + // With 3 candidates all in top 3, all 3 get medal icons. + await expect(page.locator("text=ULA Circuit")).toBeVisible({ + timeout: 5000, + }); + await expect(page.locator("text=Gossamer Gear Mariposa")).toBeVisible({ + timeout: 5000, + }); + await expect(page.locator("text=Granite Gear Crown2 38")).toBeVisible({ + timeout: 5000, + }); + + // Verify candidates are rendered (rank badges are SVG medals, not text) + // Check that at least the 3 candidates are present in the list + const candidateRows = page.locator(".bg-white.rounded-xl.border"); + await expect(candidateRows).toHaveCount(3, { timeout: 5000 }); + }); + + test("comparison view toggle works", async ({ page }) => { + await page.goto("/collection?tab=planning"); + await page.waitForLoadState("networkidle"); + + await page.getByText("New Backpack").click(); + await page.waitForLoadState("networkidle"); + + await expect(page.getByText("ULA Circuit")).toBeVisible({ timeout: 5000 }); + + // The compare button is a button with title="Compare view" (icon button with columns-3 icon) + const compareButton = page.getByRole("button", { name: "Compare view" }); + await expect(compareButton).toBeVisible({ timeout: 5000 }); + await compareButton.click(); + + // After clicking, a table should appear with Weight and Price row labels + await expect(page.locator("table")).toBeVisible({ timeout: 5000 }); + // The comparison table renders row labels in sticky cells (exact match to avoid + // matching candidate notes that contain the word "weight" or "price") + await expect( + page.getByRole("cell", { name: "Weight", exact: true }), + ).toBeVisible({ timeout: 5000 }); + await expect( + page.getByRole("cell", { name: "Price", exact: true }), + ).toBeVisible({ timeout: 5000 }); + + // All 3 candidates should appear as table column headers (in ) + await expect(page.locator("thead").getByText("ULA Circuit")).toBeVisible({ + timeout: 5000, + }); + await expect( + page.locator("thead").getByText("Gossamer Gear Mariposa"), + ).toBeVisible({ timeout: 5000 }); + }); + + test("resolved thread shows winner banner", async ({ page }) => { + await page.goto("/collection?tab=planning"); + await page.waitForLoadState("networkidle"); + + // Click the "Resolved" tab pill button in the planning view + await page.getByRole("button", { name: "Resolved" }).click(); + await page.waitForLoadState("networkidle"); + + // Camp Stove resolved thread should appear + await expect(page.getByText("Camp Stove")).toBeVisible({ timeout: 5000 }); + + // Click the Camp Stove thread card and wait for URL to change to thread detail + await page.getByText("Camp Stove").click(); + await page.waitForURL(/\/threads\/\d+/, { timeout: 5000 }); + await page.waitForLoadState("networkidle"); + + // Should show the resolved thread heading + await expect(page.getByRole("heading", { name: "Camp Stove" })).toBeVisible( + { timeout: 5000 }, + ); + + // The winner candidate (BRS-3000T) should be visible in the candidate list + await expect(page.getByText("BRS-3000T")).toBeVisible({ timeout: 5000 }); + }); +}); diff --git a/src/client/components/CandidateListItem.tsx b/src/client/components/CandidateListItem.tsx index 92125a9..cd42703 100644 --- a/src/client/components/CandidateListItem.tsx +++ b/src/client/components/CandidateListItem.tsx @@ -59,13 +59,11 @@ export function CandidateListItem({ const openResolveDialog = useUIStore((s) => s.openResolveDialog); const openExternalLink = useUIStore((s) => s.openExternalLink); - return ( - + const sharedClassName = + "flex items-center gap-3 bg-white rounded-xl border border-gray-100 p-3 hover:border-gray-200 hover:shadow-sm transition-all group cursor-default"; + + const innerContent = ( + <> {/* Drag handle */} {isActive && ( - + ); + + // Reorder.Item requires a Reorder.Group parent — only use it in active threads + if (isActive) { + return ( + + {innerContent} + + ); + } + + return
{innerContent}
; }