diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index a40123db..1a215827 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -5,13 +5,14 @@ on: branches: [ main, develop ] paths: - 'packages/web/**' + - 'packages/backend/**' - 'packages/shared-schema/**' - 'packages/db/**' - '.github/workflows/e2e-tests.yml' pull_request: - branches: [ main, develop ] paths: - 'packages/web/**' + - 'packages/backend/**' - 'packages/shared-schema/**' - 'packages/db/**' - '.github/workflows/e2e-tests.yml' @@ -20,30 +21,44 @@ jobs: e2e: timeout-minutes: 30 runs-on: ubuntu-latest + container: + image: mcr.microsoft.com/playwright:v1.57.0-noble services: postgres: - image: ghcr.io/marcodejongh/boardsesh-postgres-postgis:latest + image: ghcr.io/marcodejongh/boardsesh-dev-db:latest env: POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: boardsesh_test - ports: - - 5432:5432 + POSTGRES_PASSWORD: password + POSTGRES_DB: main options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 + --shm-size 256mb + + neon-proxy: + image: ghcr.io/timowilhelm/local-neon-http-proxy:main + env: + PG_CONNECTION_STRING: postgres://postgres:password@postgres:5432/main + POSTGRES_DB: main steps: - uses: actions/checkout@v4 + - name: Point db.localtest.me to neon-proxy service + run: | + # Resolve the neon-proxy service IP and add a hosts entry so the + # Neon driver's fetchEndpoint (http://db.localtest.me:4444/sql) reaches + # the proxy container instead of localhost. + PROXY_IP=$(getent hosts neon-proxy | awk '{ print $1 }') + echo "$PROXY_IP db.localtest.me" >> /etc/hosts + - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' - cache: 'npm' - name: Install dependencies run: npm ci --legacy-peer-deps @@ -54,9 +69,8 @@ jobs: - name: Build db package run: npm run build --workspace=@boardsesh/db - - name: Install Playwright Browsers - run: npx playwright install --with-deps chromium - working-directory: packages/web + - name: Build backend + run: npm run build --workspace=boardsesh-backend - name: Build web application run: npm run build --workspace=@boardsesh/web @@ -64,24 +78,30 @@ jobs: # Provide minimal env vars needed for build NEXTAUTH_SECRET: test-secret-for-ci NEXTAUTH_URL: http://localhost:3000 - DATABASE_URL: postgresql://postgres:postgres@localhost:5432/boardsesh_test + DATABASE_URL: postgresql://postgres:password@db.localtest.me:5432/main + NEXT_PUBLIC_WS_URL: ws://localhost:8080/graphql - name: Run database migrations run: npx drizzle-kit migrate - working-directory: packages/web + working-directory: packages/db env: - DATABASE_URL: postgresql://postgres:postgres@localhost:5432/boardsesh_test + DATABASE_URL: postgresql://postgres:password@postgres:5432/main - - name: Start server and run E2E tests + - name: Start backend and web server, then run E2E tests run: | + npm run start --workspace=boardsesh-backend & + npx wait-on http-get://localhost:8080/health --timeout 30000 npm run start --workspace=@boardsesh/web & npx wait-on http://localhost:3000 --timeout 60000 npm run test:e2e --workspace=@boardsesh/web env: PLAYWRIGHT_TEST_BASE_URL: http://localhost:3000 - DATABASE_URL: postgresql://postgres:postgres@localhost:5432/boardsesh_test + DATABASE_URL: postgresql://postgres:password@db.localtest.me:5432/main NEXTAUTH_SECRET: test-secret-for-ci NEXTAUTH_URL: http://localhost:3000 + NEXT_PUBLIC_WS_URL: ws://localhost:8080/graphql + TEST_USER_EMAIL: test@boardsesh.com + TEST_USER_PASSWORD: test - name: Upload Playwright Report uses: actions/upload-artifact@v4 diff --git a/.github/workflows/moonboard-ocr-tests.yml b/.github/workflows/moonboard-ocr-tests.yml index 6208d6e5..aea66715 100644 --- a/.github/workflows/moonboard-ocr-tests.yml +++ b/.github/workflows/moonboard-ocr-tests.yml @@ -7,7 +7,6 @@ on: - 'packages/moonboard-ocr/**' - '.github/workflows/moonboard-ocr-tests.yml' pull_request: - branches: [ main, develop ] paths: - 'packages/moonboard-ocr/**' - '.github/workflows/moonboard-ocr-tests.yml' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 12b391d0..cd2fdf77 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,7 +9,6 @@ on: - 'packages/db/**' - '.github/workflows/test.yml' pull_request: - branches: [ main, develop ] paths: - 'packages/web/**' - 'packages/shared-schema/**' diff --git a/packages/web/app/components/activity-feed/__tests__/activity-feed.test.tsx b/packages/web/app/components/activity-feed/__tests__/activity-feed.test.tsx new file mode 100644 index 00000000..d5f84967 --- /dev/null +++ b/packages/web/app/components/activity-feed/__tests__/activity-feed.test.tsx @@ -0,0 +1,260 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import '@testing-library/jest-dom/vitest'; +import { render, screen, waitFor, act } from '@testing-library/react'; +import React from 'react'; + +// --------------------------------------------------------------------------- +// Mocks — must be defined before importing the SUT +// --------------------------------------------------------------------------- + +const mockWsAuth = { + token: null as string | null, + isAuthenticated: false, + isLoading: false, + error: null as string | null, +}; + +vi.mock('@/app/hooks/use-ws-auth-token', () => ({ + useWsAuthToken: () => mockWsAuth, +})); + +const mockRequest = vi.fn(); + +vi.mock('@/app/lib/graphql/client', () => ({ + createGraphQLHttpClient: () => ({ request: mockRequest }), +})); + +// Provide a minimal QueryClient wrapper since useWsAuthToken uses TanStack Query +// (but we've mocked it, so this is just for potential other hooks) +vi.mock('@tanstack/react-query', async () => { + const actual = await vi.importActual('@tanstack/react-query'); + return { + ...actual, + useQuery: vi.fn().mockReturnValue({ data: undefined, isLoading: false, error: null }), + }; +}); + +// Now import the SUT — after all vi.mock calls +import ActivityFeed from '../activity-feed'; +import type { ActivityFeedItem } from '@boardsesh/shared-schema'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createMockItem(overrides?: Partial): ActivityFeedItem { + return { + id: `item-${Math.random().toString(36).slice(2)}`, + type: 'ascent', + entityType: 'tick', + entityId: 'entity-1', + actorDisplayName: 'Test Climber', + climbName: 'Test Climb V3', + boardType: 'kilter', + createdAt: new Date().toISOString(), + ...overrides, + }; +} + +function createFeedResponse(items: ActivityFeedItem[], hasMore = false) { + return { + items, + cursor: hasMore ? 'next-cursor' : null, + hasMore, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('ActivityFeed', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockWsAuth.token = null; + mockWsAuth.isAuthenticated = false; + mockWsAuth.isLoading = false; + mockWsAuth.error = null; + }); + + describe('authSessionLoading behavior', () => { + it('shows initialItems when authSessionLoading is true', () => { + const items = [createMockItem({ climbName: 'Boulder Problem Alpha' })]; + + render( + , + ); + + expect(screen.getByText('Boulder Problem Alpha')).toBeInTheDocument(); + expect(mockRequest).not.toHaveBeenCalled(); + expect(screen.queryByText('Follow climbers to see their activity here')).not.toBeInTheDocument(); + }); + + it('shows loading spinner when authSessionLoading is true and no initialItems', () => { + render( + , + ); + + expect(screen.getByTestId('activity-feed-loading')).toBeInTheDocument(); + expect(screen.queryByText('Follow climbers to see their activity here')).not.toBeInTheDocument(); + expect(mockRequest).not.toHaveBeenCalled(); + }); + }); + + describe('feed type selection', () => { + it('fetches trending feed when unauthenticated', async () => { + const trendingItems = [createMockItem({ climbName: 'Trending Route' })]; + mockRequest.mockResolvedValueOnce({ + trendingFeed: createFeedResponse(trendingItems), + }); + + render( + , + ); + + await waitFor(() => { + expect(mockRequest).toHaveBeenCalledTimes(1); + }); + + // Should have called with the trending feed query (second positional arg: GET_TRENDING_FEED) + const callArgs = mockRequest.mock.calls[0]; + expect(callArgs[0]).toContain('trendingFeed'); + }); + + it('fetches authenticated feed when authenticated with token', async () => { + mockWsAuth.token = 'jwt-token'; + mockWsAuth.isAuthenticated = true; + + const authItems = [createMockItem({ climbName: 'My Feed Route' })]; + mockRequest.mockResolvedValueOnce({ + activityFeed: createFeedResponse(authItems), + }); + + render( + , + ); + + await waitFor(() => { + expect(mockRequest).toHaveBeenCalledTimes(1); + }); + + const callArgs = mockRequest.mock.calls[0]; + expect(callArgs[0]).toContain('activityFeed'); + }); + }); + + describe('stale fetch protection', () => { + it('discards stale fetch results when a newer fetch supersedes', async () => { + // First render: unauthenticated, slow response + let resolveFirst: (value: unknown) => void; + const firstPromise = new Promise((resolve) => { resolveFirst = resolve; }); + + const staleItems = [createMockItem({ climbName: 'Stale Item' })]; + const freshItems = [createMockItem({ climbName: 'Fresh Item' })]; + + mockRequest.mockReturnValueOnce(firstPromise); + + const { rerender } = render( + , + ); + + // Wait for the first fetch to be initiated + await waitFor(() => { + expect(mockRequest).toHaveBeenCalledTimes(1); + }); + + // Second render: now authenticated with token (session resolved) + mockWsAuth.token = 'jwt-token'; + mockWsAuth.isAuthenticated = true; + + mockRequest.mockResolvedValueOnce({ + activityFeed: createFeedResponse(freshItems), + }); + + rerender( + , + ); + + // Wait for fresh fetch to complete + await waitFor(() => { + expect(screen.getByText('Fresh Item')).toBeInTheDocument(); + }); + + // Now resolve the stale first fetch — it should be discarded + await act(async () => { + resolveFirst!({ + trendingFeed: createFeedResponse(staleItems), + }); + }); + + // Fresh items should still be shown, stale items should not appear + expect(screen.getByText('Fresh Item')).toBeInTheDocument(); + expect(screen.queryByText('Stale Item')).not.toBeInTheDocument(); + }); + }); + + describe('empty state protection', () => { + it('never shows empty state while waiting for auth token', async () => { + mockWsAuth.token = null; + mockWsAuth.isLoading = true; + + const items = [createMockItem({ climbName: 'Existing Item' })]; + + render( + , + ); + + // Items should remain visible + expect(screen.getByText('Existing Item')).toBeInTheDocument(); + // Empty state should not appear + expect(screen.queryByText('Follow climbers to see their activity here')).not.toBeInTheDocument(); + // No fetch should have been made yet (waiting for token) + expect(mockRequest).not.toHaveBeenCalled(); + }); + + it('shows empty state only after authenticated fetch returns empty', async () => { + mockWsAuth.token = 'jwt-token'; + mockWsAuth.isAuthenticated = true; + + mockRequest.mockResolvedValueOnce({ + activityFeed: createFeedResponse([]), + }); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByTestId('activity-feed-empty-state')).toBeInTheDocument(); + }); + + expect(screen.getByText('Follow climbers to see their activity here')).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/web/app/components/activity-feed/activity-feed.tsx b/packages/web/app/components/activity-feed/activity-feed.tsx index 93e6b174..50d8c775 100644 --- a/packages/web/app/components/activity-feed/activity-feed.tsx +++ b/packages/web/app/components/activity-feed/activity-feed.tsx @@ -25,6 +25,8 @@ import SessionSummaryFeedItem from './session-summary-feed-item'; interface ActivityFeedProps { isAuthenticated: boolean; + /** Whether the NextAuth session is still loading. Prevents premature fetches. */ + authSessionLoading?: boolean; boardUuid?: string | null; sortBy?: SortMode; topPeriod?: TimePeriod; @@ -50,6 +52,7 @@ function renderFeedItem(item: ActivityFeedItem) { export default function ActivityFeed({ isAuthenticated, + authSessionLoading = false, boardUuid, sortBy = 'new', topPeriod = 'all', @@ -66,8 +69,10 @@ export default function ActivityFeed({ // Track dependencies for reset const prevDeps = useRef({ boardUuid, sortBy }); + // Monotonically increasing ID to discard stale fetch results + const fetchIdRef = useRef(0); - const fetchFeed = useCallback(async (cursorValue: string | null = null) => { + const fetchFeed = useCallback(async (cursorValue: string | null = null, currentFetchId?: number) => { if (cursorValue === null) { setLoading(true); } else { @@ -91,6 +96,10 @@ export default function ActivityFeed({ GET_ACTIVITY_FEED, { input } ); + + // Discard stale results if a newer fetch has been initiated + if (currentFetchId !== undefined && currentFetchId !== fetchIdRef.current) return; + const { items: newItems, cursor: nextCursor, hasMore: more } = response.activityFeed; if (cursorValue === null) { @@ -105,6 +114,10 @@ export default function ActivityFeed({ GET_TRENDING_FEED, { input } ); + + // Discard stale results if a newer fetch has been initiated + if (currentFetchId !== undefined && currentFetchId !== fetchIdRef.current) return; + const { items: newItems, cursor: nextCursor, hasMore: more } = response.trendingFeed; if (cursorValue === null) { @@ -116,18 +129,26 @@ export default function ActivityFeed({ setHasMore(more); } } catch (err) { + // Discard stale errors too + if (currentFetchId !== undefined && currentFetchId !== fetchIdRef.current) return; console.error('Error fetching activity feed:', err); if (cursorValue === null) { setError('Failed to load activity feed. Please try again.'); } } finally { - setLoading(false); - setLoadingMore(false); + // Only clear loading if this fetch is still current + if (currentFetchId === undefined || currentFetchId === fetchIdRef.current) { + setLoading(false); + setLoadingMore(false); + } } }, [isAuthenticated, token, boardUuid, sortBy, topPeriod]); // Initial load and reset on dependency changes useEffect(() => { + // Don't fetch while the NextAuth session is still resolving + if (authSessionLoading) return; + const depsChanged = prevDeps.current.boardUuid !== boardUuid || prevDeps.current.sortBy !== sortBy; @@ -148,12 +169,13 @@ export default function ActivityFeed({ setError(null); } - fetchFeed(null); - }, [isAuthenticated, token, authLoading, boardUuid, sortBy, fetchFeed]); + const id = ++fetchIdRef.current; + fetchFeed(null, id); + }, [authSessionLoading, isAuthenticated, token, authLoading, boardUuid, sortBy, fetchFeed]); - if ((authLoading || loading) && items.length === 0) { + if ((authSessionLoading || authLoading || loading) && items.length === 0) { return ( - + ); @@ -172,7 +194,7 @@ export default function ActivityFeed({ icon={} description={error} > - fetchFeed(null)}> + fetchFeed(null, ++fetchIdRef.current)}> Retry @@ -180,16 +202,18 @@ export default function ActivityFeed({ {!error && items.length === 0 ? ( isAuthenticated ? ( - } - description="Follow climbers to see their activity here" - > - {onFindClimbers && ( - - Find Climbers - - )} - + + } + description="Follow climbers to see their activity here" + > + {onFindClimbers && ( + + Find Climbers + + )} + + ) : ( } @@ -197,12 +221,12 @@ export default function ActivityFeed({ /> ) ) : ( - <> + {items.map(renderFeedItem)} {hasMore && ( fetchFeed(cursor)} + onClick={() => fetchFeed(cursor, fetchIdRef.current)} disabled={loadingMore} variant="outlined" fullWidth @@ -211,7 +235,7 @@ export default function ActivityFeed({ )} - + )} ); diff --git a/packages/web/app/components/board-page/share-button.tsx b/packages/web/app/components/board-page/share-button.tsx index dea90482..d8b034cb 100644 --- a/packages/web/app/components/board-page/share-button.tsx +++ b/packages/web/app/components/board-page/share-button.tsx @@ -477,6 +477,7 @@ export const ShareBoardButton = ({ buttonType = 'default' }: { buttonType?: 'def <> diff --git a/packages/web/app/components/bottom-tab-bar/bottom-tab-bar.tsx b/packages/web/app/components/bottom-tab-bar/bottom-tab-bar.tsx index a51805bf..02dac7e3 100644 --- a/packages/web/app/components/bottom-tab-bar/bottom-tab-bar.tsx +++ b/packages/web/app/components/bottom-tab-bar/bottom-tab-bar.tsx @@ -304,6 +304,7 @@ function BottomTabBar({ boardDetails, angle, boardConfigs }: BottomTabBarProps) return ( <> +
{hasActiveQueue && boardDetails && ( diff --git a/packages/web/app/help/help-content.tsx b/packages/web/app/help/help-content.tsx index c14079c0..98bb69ee 100644 --- a/packages/web/app/help/help-content.tsx +++ b/packages/web/app/help/help-content.tsx @@ -52,7 +52,7 @@ const helpSections = [ To access the heatmap:
    -
  1. Go to the "Search by Hold" tab in the side panel
  2. +
  3. Open the search drawer and expand the "Holds" section
  4. Click the "Show Heatmap" button
  5. The board will display a color overlay from green (low usage) to red (high usage)
@@ -132,7 +132,7 @@ const helpSections = [ To start a session:
    -
  1. Click the team icon in the header
  2. +
  3. Tap the group icon in the queue bar at the bottom
  4. Sign in if not already logged in
  5. Click "Start Party Mode" to create a new session
  6. Share the session with friends using QR code or link
  7. @@ -177,7 +177,7 @@ const helpSections = [ Queue actions:
      -
    • Add climbs: Click the + icon on any climb card
    • +
    • Add climbs: Double-tap any climb in the list to add it to your queue
    • Reorder: Drag and drop climbs to change order
    • Set current: Click a climb to make it the active climb
    • Remove: Click the X on any queued climb
    • @@ -324,7 +324,7 @@ const helpSections = [ To search by holds:
        -
      1. Go to the "Search by Hold" tab
      2. +
      3. Open the search drawer and expand the "Holds" section
      4. Select "Include" or "Exclude" mode from the dropdown
      5. Tap holds on the board to select them
      6. Results update automatically as you select holds
      7. @@ -371,7 +371,7 @@ const helpSections = [ To connect:
          -
        1. Click the lightbulb icon in the header
        2. +
        3. Tap the group icon in the queue bar, then go to the "Connect to Board" tab
        4. Select your board from the device list
        5. Once connected, LEDs automatically show the current climb
        @@ -400,7 +400,7 @@ const helpSections = [ To link your account:
          -
        1. Go to Settings (click your profile icon)
        2. +
        3. Open the menu (tap your avatar) and go to Settings
        4. Find the "Aurora Accounts" section
        5. Enter your Kilter or Tension credentials
        6. Click "Link Account"
        7. diff --git a/packages/web/app/home-page-content.tsx b/packages/web/app/home-page-content.tsx index 288a3164..b6f0db3a 100644 --- a/packages/web/app/home-page-content.tsx +++ b/packages/web/app/home-page-content.tsx @@ -59,6 +59,7 @@ export default function HomePageContent({ const [subscriptions, setSubscriptions] = useState([]); const isAuthenticated = status === 'authenticated' && !!session?.user; + const authSessionLoading = status === 'loading'; const { token: wsAuthToken } = useWsAuthToken(); const { boards: myBoards, isLoading: isLoadingBoards } = useMyBoards(isAuthenticated); @@ -199,6 +200,7 @@ export default function HomePageContent({ setSearchOpen(true)} diff --git a/packages/web/e2e/activity-feed.spec.ts b/packages/web/e2e/activity-feed.spec.ts new file mode 100644 index 00000000..3210c537 --- /dev/null +++ b/packages/web/e2e/activity-feed.spec.ts @@ -0,0 +1,128 @@ +/** + * Activity Feed E2E Tests + * + * Validates that the activity feed loads correctly for both authenticated + * and unauthenticated users, and that authenticated users never see a + * flash of the empty state ("Follow climbers...") due to race conditions. + * + * Prerequisites: + * - Dev server running: npm run dev + * - For authenticated tests: TEST_USER_EMAIL and TEST_USER_PASSWORD env vars + * (defaults: test@boardsesh.com / test from the dev-db Docker image) + */ +import { test, expect, type Page } from '@playwright/test'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async function loginAsTestUser(page: Page) { + const email = process.env.TEST_USER_EMAIL || 'test@boardsesh.com'; + const password = process.env.TEST_USER_PASSWORD || 'test'; + + // Open user drawer and click "Sign in" + await page.getByLabel('User menu').click(); + await page.waitForSelector('.MuiDrawer-root .MuiPaper-root', { state: 'visible' }); + await page.getByRole('button', { name: 'Sign in' }).click(); + await page.waitForSelector('input#login_email', { state: 'visible' }); + + // Fill login form + await page.locator('input#login_email').fill(email); + await page.locator('input#login_password').fill(password); + await page.locator('button[type="submit"]').filter({ hasText: 'Login' }).click(); + + // Wait for login modal to close + await page.waitForSelector('input#login_email', { state: 'hidden', timeout: 10000 }); +} + +// --------------------------------------------------------------------------- +// Unauthenticated tests +// --------------------------------------------------------------------------- + +test.describe('Activity Feed — Unauthenticated', () => { + test('trending feed loads with items', async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + + // Feed items should be visible + const feedItems = page.locator('[data-testid="activity-feed-items"]'); + await expect(feedItems).toBeVisible({ timeout: 15000 }); + + // "Follow climbers" empty state should NOT be visible + await expect(page.getByText('Follow climbers to see their activity here')).not.toBeVisible(); + }); +}); + +// --------------------------------------------------------------------------- +// Authenticated tests +// --------------------------------------------------------------------------- + +test.describe('Activity Feed — Authenticated', () => { + const testEmail = process.env.TEST_USER_EMAIL; + const testPassword = process.env.TEST_USER_PASSWORD; + + test.skip( + !testEmail && !testPassword && process.env.CI !== 'true', + 'Set TEST_USER_EMAIL and TEST_USER_PASSWORD env vars to run authenticated tests (skipped outside CI)', + ); + + test('activity feed loads without flashing empty state', async ({ page }) => { + // Login first on a board page (where the user drawer is available) + await page.goto('/'); + await page.waitForLoadState('networkidle'); + await loginAsTestUser(page); + + // Install a MutationObserver BEFORE navigating to catch any flash of the empty state + await page.evaluate(() => { + (window as unknown as Record).__emptyStateFlashed = false; + const observer = new MutationObserver(() => { + if (document.querySelector('[data-testid="activity-feed-empty-state"]')) { + (window as unknown as Record).__emptyStateFlashed = true; + } + }); + observer.observe(document.body, { childList: true, subtree: true }); + }); + + // Navigate to home page + await page.goto('/'); + + // Wait for feed items to appear + const feedItems = page.locator('[data-testid="activity-feed-items"]'); + await expect(feedItems).toBeVisible({ timeout: 20000 }); + + // Check that the empty state was never flashed + const emptyStateFlashed = await page.evaluate( + () => (window as unknown as Record).__emptyStateFlashed, + ); + expect(emptyStateFlashed).toBe(false); + }); + + test('feed persists through sort change', async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + await loginAsTestUser(page); + + // Navigate to home and wait for feed + await page.goto('/'); + const feedItems = page.locator('[data-testid="activity-feed-items"]'); + await expect(feedItems).toBeVisible({ timeout: 20000 }); + + // Change sort mode by clicking the sort selector + const sortButton = page.getByRole('button', { name: /new|top|hot|controversial/i }); + if (await sortButton.isVisible()) { + await sortButton.click(); + + // Pick a different sort option from the menu + const topOption = page.getByRole('menuitem', { name: /top/i }); + if (await topOption.isVisible({ timeout: 2000 }).catch(() => false)) { + await topOption.click(); + + // Feed should load items (not show a permanent empty state) + await expect(feedItems).toBeVisible({ timeout: 15000 }); + } + } + + // "Follow climbers" empty state should not be visible + await expect(page.getByText('Follow climbers to see their activity here')).not.toBeVisible(); + }); +}); diff --git a/packages/web/e2e/bottom-tab-bar.spec.ts b/packages/web/e2e/bottom-tab-bar.spec.ts new file mode 100644 index 00000000..c7f4d542 --- /dev/null +++ b/packages/web/e2e/bottom-tab-bar.spec.ts @@ -0,0 +1,201 @@ +import { test, expect, Page } from '@playwright/test'; + +/** + * E2E tests for the bottom tab bar navigation. + * + * These tests verify that the bottom tab bar is always visible, + * navigation works correctly, active states are displayed, and + * it coexists properly with the queue control bar. + */ + +const boardUrl = '/kilter/original/12x12-square/screw_bolt/40/list'; +const bottomTabBar = '[data-testid="bottom-tab-bar"]'; +const queueControlBar = '[data-testid="queue-control-bar"]'; + +async function waitForPageReady(page: Page) { + await page.waitForLoadState('networkidle'); + await expect(page.locator(bottomTabBar)).toBeVisible({ timeout: 15000 }); +} + +// Scoped tab button selector to avoid ambiguity with multiple bars during transitions +function bottomTabButton(page: Page, name: string, exact = false) { + return page.locator(bottomTabBar).getByRole('button', { name, exact }); +} + +test.describe('Bottom Tab Bar - Visibility', () => { + test('should be visible on the home page', async ({ page }) => { + await page.goto('/'); + await waitForPageReady(page); + await expect(page.locator(bottomTabBar)).toBeVisible(); + }); + + test('should be visible on a board page', async ({ page }) => { + await page.goto(boardUrl); + await waitForPageReady(page); + await expect(page.locator(bottomTabBar)).toBeVisible(); + }); + + test('should be visible on the settings page', async ({ page }) => { + await page.goto('/settings'); + await waitForPageReady(page); + await expect(page.locator(bottomTabBar)).toBeVisible(); + }); + + test('should be visible on the notifications page', async ({ page }) => { + await page.goto('/notifications'); + await waitForPageReady(page); + await expect(page.locator(bottomTabBar)).toBeVisible(); + }); + + test('should be visible on the my-library page', async ({ page }) => { + await page.goto('/my-library'); + await waitForPageReady(page); + await expect(page.locator(bottomTabBar)).toBeVisible(); + }); +}); + +test.describe('Bottom Tab Bar - Navigation', () => { + test('Home tab should navigate to home page', async ({ page }) => { + await page.goto(boardUrl); + await waitForPageReady(page); + + await bottomTabButton(page, 'Home').click(); + await expect(page).toHaveURL('/', { timeout: 15000 }); + await expect(page.locator(bottomTabBar)).toBeVisible(); + }); + + test('Climb tab should navigate to board page', async ({ page }) => { + // First visit a board page to establish board context in IndexedDB + await page.goto(boardUrl); + await waitForPageReady(page); + + // Navigate to home + await bottomTabButton(page, 'Home').click(); + await expect(page).toHaveURL('/', { timeout: 15000 }); + + // Now click Climb - should navigate back using last used board + await bottomTabButton(page, 'Climb', true).click(); + await expect(page).toHaveURL(/\/(kilter|tension)\//, { timeout: 15000 }); + await expect(page.locator(bottomTabBar)).toBeVisible(); + }); + + test('Your Library tab should navigate to my-library page', async ({ page }) => { + await page.goto('/'); + await waitForPageReady(page); + + await bottomTabButton(page, 'Your Library').click(); + await expect(page).toHaveURL(/\/my-library/, { timeout: 15000 }); + await expect(page.locator(bottomTabBar)).toBeVisible(); + }); + + test('Notifications tab should navigate to notifications page', async ({ page }) => { + await page.goto('/'); + await waitForPageReady(page); + + await bottomTabButton(page, 'Notifications').click(); + await expect(page).toHaveURL(/\/notifications/, { timeout: 15000 }); + await expect(page.locator(bottomTabBar)).toBeVisible(); + }); +}); + +test.describe('Bottom Tab Bar - Active State', () => { + // MUI BottomNavigationAction does not render aria-selected; the Mui-selected + // CSS class is the only indicator of the active tab state. + test('Home tab should be active on home page', async ({ page }) => { + await page.goto('/'); + await waitForPageReady(page); + + await expect(bottomTabButton(page, 'Home')).toHaveClass(/Mui-selected/); + }); + + test('Climb tab should be active on board routes', async ({ page }) => { + await page.goto(boardUrl); + await waitForPageReady(page); + + await expect(bottomTabButton(page, 'Climb', true)).toHaveClass(/Mui-selected/); + }); + + test('Your Library tab should be active on my-library page', async ({ page }) => { + await page.goto('/my-library'); + await waitForPageReady(page); + + await expect(bottomTabButton(page, 'Your Library')).toHaveClass(/Mui-selected/); + }); + + test('Notifications tab should be active on notifications page', async ({ page }) => { + await page.goto('/notifications'); + await waitForPageReady(page); + + await expect(bottomTabButton(page, 'Notifications')).toHaveClass(/Mui-selected/); + }); +}); + +test.describe('Bottom Tab Bar - Queue Integration', () => { + test('queue bar and bottom tab bar should coexist with correct climb', async ({ page }) => { + await page.goto(boardUrl); + await page + .waitForSelector('#onboarding-climb-card, [data-testid="climb-card"]', { timeout: 30000 }) + .catch(() => page.waitForLoadState('networkidle')); + + // Add a climb to the queue + const climbCard = page.locator('#onboarding-climb-card'); + await expect(climbCard).toBeVisible({ timeout: 15000 }); + await climbCard.dblclick(); + + // Both bars should be visible + await expect(page.locator(queueControlBar)).toBeVisible({ timeout: 10000 }); + await expect(page.locator(bottomTabBar)).toBeVisible(); + + // Verify the queue bar shows the climb name + const queueToggle = page.locator('#onboarding-queue-toggle'); + await expect(queueToggle).toBeVisible({ timeout: 5000 }); + const climbName = ((await queueToggle.textContent()) ?? '').trim(); + expect(climbName).toBeTruthy(); + await expect(page.locator(queueControlBar)).toContainText(climbName); + }); + + test('queue bar should persist with correct climb across tab navigations', async ({ page }) => { + await page.goto(boardUrl); + await page + .waitForSelector('#onboarding-climb-card, [data-testid="climb-card"]', { timeout: 30000 }) + .catch(() => page.waitForLoadState('networkidle')); + + // Add a climb to the queue and capture its name + const climbCard = page.locator('#onboarding-climb-card'); + await expect(climbCard).toBeVisible({ timeout: 15000 }); + await climbCard.dblclick(); + + await expect(page.locator(queueControlBar)).toBeVisible({ timeout: 10000 }); + const queueToggle = page.locator('#onboarding-queue-toggle'); + await expect(queueToggle).toBeVisible({ timeout: 5000 }); + const climbName = ((await queueToggle.textContent()) ?? '').trim(); + expect(climbName).toBeTruthy(); + + // Helper to verify queue bar and bottom tab bar on any page + const verifyBarsShowClimb = async (timeout = 5000) => { + await expect(page.locator(queueControlBar)).toBeVisible({ timeout: 10000 }); + await expect(page.locator(queueControlBar)).toContainText(climbName, { timeout }); + await expect(page.locator(bottomTabBar)).toBeVisible(); + }; + + // Navigate to Home + await bottomTabButton(page, 'Home').click(); + await expect(page).toHaveURL('/', { timeout: 15000 }); + await verifyBarsShowClimb(); + + // Navigate to Your Library + await bottomTabButton(page, 'Your Library').click(); + await expect(page).toHaveURL(/\/my-library/, { timeout: 15000 }); + await verifyBarsShowClimb(); + + // Navigate to Notifications + await bottomTabButton(page, 'Notifications').click(); + await expect(page).toHaveURL(/\/notifications/, { timeout: 15000 }); + await verifyBarsShowClimb(); + + // Navigate back to Climb + await bottomTabButton(page, 'Climb', true).click(); + await expect(page).toHaveURL(/\/kilter\//, { timeout: 20000 }); + await verifyBarsShowClimb(15000); + }); +}); diff --git a/packages/web/e2e/help-screenshots.spec.ts b/packages/web/e2e/help-screenshots.spec.ts index a6072482..7790497c 100644 --- a/packages/web/e2e/help-screenshots.spec.ts +++ b/packages/web/e2e/help-screenshots.spec.ts @@ -15,31 +15,16 @@ * - Dev server running: npm run dev * - For authenticated tests: 1Password CLI installed and signed in */ -import { test } from '@playwright/test'; +import { test, expect } from '@playwright/test'; const SCREENSHOT_DIR = 'public/help'; +const boardUrl = '/kilter/original/12x12-square/screw_bolt/40/list'; test.describe('Help Page Screenshots', () => { test.beforeEach(async ({ page }) => { - // Start from home, select Kilter board with defaults - await page.goto('/'); - - // Wait for the page to load - await page.waitForSelector('[role="combobox"]'); - - // Click the board dropdown and select Kilter - await page.locator('input[role="combobox"]').first().click(); - await page.getByRole('option', { name: 'Kilter' }).click(); - - // Click Start Climbing button - await page.getByRole('button', { name: 'Start Climbing' }).click(); - - // Wait for the board page to load - wait for climb list or board to render - await page.waitForURL(/\/kilter\//); - await page.waitForSelector('[data-testid="board-renderer"], [role="list"]', { timeout: 10000 }).catch(() => { - // Fallback: wait for any main content to appear - return page.waitForSelector('main, [role="main"]', { state: 'visible' }); - }); + await page.goto(boardUrl); + await page.waitForSelector('#onboarding-climb-card, [data-testid="climb-card"]', { timeout: 30000 }) + .catch(() => page.waitForLoadState('networkidle')); }); test('main interface', async ({ page }) => { @@ -47,53 +32,76 @@ test.describe('Help Page Screenshots', () => { }); test('search filters', async ({ page }) => { - await page.getByRole('tab', { name: 'Search', exact: true }).click(); - // Wait for search form content to be visible - await page.waitForSelector('.MuiAccordion-root, form', { state: 'visible' }); + // Open search drawer via the search pill in the header + await page.locator('#onboarding-search-button').click(); + await page.waitForSelector('.MuiDrawer-root .MuiPaper-root', { state: 'visible' }); await page.screenshot({ path: `${SCREENSHOT_DIR}/search-filters.png` }); }); test('search by hold', async ({ page }) => { - await page.getByRole('tab', { name: 'Search by Hold' }).click(); - // Wait for the hold search tab content - await page.waitForSelector('button:has-text("Show Heatmap"), button:has-text("Hide Heatmap")', { state: 'visible' }); + // Open search drawer and expand the "Holds" section + await page.locator('#onboarding-search-button').click(); + await page.waitForSelector('.MuiDrawer-root .MuiPaper-root', { state: 'visible' }); + // Click the "Holds" collapsible section to expand it + await page.getByText('Holds').click(); await page.screenshot({ path: `${SCREENSHOT_DIR}/search-by-hold.png` }); }); test('heatmap', async ({ page }) => { - await page.getByRole('tab', { name: 'Search by Hold' }).click(); + // Open search drawer and expand the "Holds" section + await page.locator('#onboarding-search-button').click(); + await page.waitForSelector('.MuiDrawer-root .MuiPaper-root', { state: 'visible' }); + await page.getByText('Holds').click(); + + // Click "Show Heatmap" button within the holds section await page.getByRole('button', { name: 'Show Heatmap' }).click(); // Wait for heatmap loading to complete await page.waitForSelector('text=Loading heatmap...', { state: 'hidden', timeout: 10000 }).catch(() => {}); - // Wait for heatmap controls or canvas to be visible await page.waitForSelector('button:has-text("Hide Heatmap")', { state: 'visible' }); await page.screenshot({ path: `${SCREENSHOT_DIR}/heatmap.png` }); }); test('climb detail', async ({ page }) => { - // Click on the first climb's info button - await page.getByRole('link', { name: 'info-circle' }).first().click(); - await page.waitForURL(/\/view\//); - // Wait for climb details to load - await page.waitForSelector('.MuiCard-root', { state: 'visible' }); + // Double-click first climb to add it to the queue + const climbCard = page.locator('#onboarding-climb-card'); + await climbCard.dblclick(); + + // Wait for queue bar to appear, then click it to open the play drawer + const queueBar = page.locator('[data-testid="queue-control-bar"]'); + await expect(queueBar).toBeVisible({ timeout: 10000 }); + // Click the queue toggle text to open play drawer with climb details + await page.locator('#onboarding-queue-toggle').click(); + await page.waitForSelector('.MuiDrawer-root .MuiPaper-root', { state: 'visible' }); await page.screenshot({ path: `${SCREENSHOT_DIR}/climb-detail.png` }); }); test('party mode modal', async ({ page }) => { - await page.getByRole('button', { name: 'team' }).click(); - // Wait for drawer content to be visible + // First add a climb to queue so the queue bar appears + const climbCard = page.locator('#onboarding-climb-card'); + await climbCard.dblclick(); + + // Wait for queue bar + const queueBar = page.locator('[data-testid="queue-control-bar"]'); + await expect(queueBar).toBeVisible({ timeout: 10000 }); + + // Click party mode button in the queue bar + await page.getByLabel('Party Mode').click(); await page.waitForSelector('.MuiDrawer-root .MuiPaper-root', { state: 'visible' }); await page.screenshot({ path: `${SCREENSHOT_DIR}/party-mode.png` }); }); test('login modal', async ({ page }) => { - await page.getByRole('button', { name: 'Login' }).click(); - // Wait for modal with login form to be visible - await page.waitForSelector('.MuiDialog-root, .MuiModal-root', { state: 'visible' }); + // Open user drawer + await page.getByLabel('User menu').click(); + await page.waitForSelector('.MuiDrawer-root .MuiPaper-root', { state: 'visible' }); + + // Click "Sign in" button in the user drawer + await page.getByRole('button', { name: 'Sign in' }).click(); + // Wait for auth modal with login form await page.waitForSelector('input#login_email', { state: 'visible' }); await page.screenshot({ path: `${SCREENSHOT_DIR}/login-modal.png` }); @@ -108,19 +116,14 @@ test.describe('Help Page Screenshots - Authenticated', () => { test.skip(!testEmail || !testPassword, 'Set TEST_USER_EMAIL and TEST_USER_PASSWORD env vars to run authenticated tests'); test.beforeEach(async ({ page }) => { - // Navigate to board page first - await page.goto('/'); - await page.waitForSelector('[role="combobox"]'); - await page.locator('input[role="combobox"]').first().click(); - await page.getByRole('option', { name: 'Kilter' }).click(); - await page.getByRole('button', { name: 'Start Climbing' }).click(); - await page.waitForURL(/\/kilter\//); - await page.waitForSelector('[data-testid="board-renderer"], [role="list"]', { timeout: 10000 }).catch(() => { - return page.waitForSelector('main, [role="main"]', { state: 'visible' }); - }); + await page.goto(boardUrl); + await page.waitForSelector('#onboarding-climb-card, [data-testid="climb-card"]', { timeout: 30000 }) + .catch(() => page.waitForLoadState('networkidle')); - // Login via auth modal - await page.getByRole('button', { name: 'Login' }).click(); + // Login via user drawer + await page.getByLabel('User menu').click(); + await page.waitForSelector('.MuiDrawer-root .MuiPaper-root', { state: 'visible' }); + await page.getByRole('button', { name: 'Sign in' }).click(); await page.waitForSelector('input#login_email', { state: 'visible' }); // Fill login form @@ -128,42 +131,38 @@ test.describe('Help Page Screenshots - Authenticated', () => { await page.locator('input#login_password').fill(testPassword!); await page.locator('button[type="submit"]').filter({ hasText: 'Login' }).click(); - // Wait for login to complete - modal should close and user button should appear - await page.waitForSelector('.MuiDialog-root, .MuiModal-root', { state: 'hidden', timeout: 10000 }); - await page.waitForSelector('[data-testid="PersonIcon"], .MuiSvgIcon-root', { state: 'visible', timeout: 5000 }).catch(() => { - // Alternative: wait for any indication of logged-in state - return page.waitForSelector('text=Logout', { state: 'attached' }); - }); + // Wait for login to complete - auth modal should close + await page.waitForSelector('input#login_email', { state: 'hidden', timeout: 10000 }); }); test('personal progress filters', async ({ page }) => { - // Open search tab to show personal progress filters - await page.getByRole('tab', { name: 'Search', exact: true }).click(); - await page.waitForSelector('.MuiAccordion-root, form', { state: 'visible' }); + // Open search drawer to show filters including personal progress + await page.locator('#onboarding-search-button').click(); + await page.waitForSelector('.MuiDrawer-root .MuiPaper-root', { state: 'visible' }); - // Scroll to Personal Progress section - await page.evaluate(() => { - const headers = document.querySelectorAll('.MuiAccordionSummary-content'); - for (const header of headers) { - if (header.textContent?.includes('Personal Progress')) { - header.scrollIntoView({ behavior: 'instant', block: 'center' }); - break; - } - } - }); + // Expand the Progress section + await page.getByText('Progress').click(); await page.screenshot({ path: `${SCREENSHOT_DIR}/personal-progress.png` }); }); test('party mode active session', async ({ page }) => { + // First add a climb to queue so the queue bar appears + const climbCard = page.locator('#onboarding-climb-card'); + await climbCard.dblclick(); + + // Wait for queue bar + const queueBar = page.locator('[data-testid="queue-control-bar"]'); + await expect(queueBar).toBeVisible({ timeout: 10000 }); + // Open party mode drawer - await page.getByRole('button', { name: 'team' }).click(); + await page.getByLabel('Party Mode').click(); await page.waitForSelector('.MuiDrawer-root .MuiPaper-root', { state: 'visible' }); // Start a party session await page.getByRole('button', { name: 'Start Party Mode' }).click(); - // Wait for session to be active - look for Leave button or session ID indicator + // Wait for session to be active - look for Leave button await page.waitForSelector('button:has-text("Leave")', { state: 'visible', timeout: 10000 }); await page.screenshot({ path: `${SCREENSHOT_DIR}/party-mode-active.png` }); @@ -173,14 +172,13 @@ test.describe('Help Page Screenshots - Authenticated', () => { }); test('hold classification wizard', async ({ page }) => { - // Open user menu and click Classify Holds - await page.locator('[data-testid="PersonIcon"]').first().click(); - await page.waitForSelector('.MuiMenu-root, .MuiPopover-root', { state: 'visible' }); + // Open user drawer and click Classify Holds + await page.getByLabel('User menu').click(); + await page.waitForSelector('.MuiDrawer-root .MuiPaper-root', { state: 'visible' }); await page.getByText('Classify Holds').click(); // Wait for wizard drawer to open and content to load await page.waitForSelector('.MuiDrawer-root .MuiPaper-root', { state: 'visible' }); - // Wait for hold content or progress indicator await page.waitForSelector('.MuiRating-root, .MuiLinearProgress-root', { state: 'visible', timeout: 10000 }); await page.screenshot({ path: `${SCREENSHOT_DIR}/hold-classification.png` }); diff --git a/packages/web/e2e/queue-persistence.spec.ts b/packages/web/e2e/queue-persistence.spec.ts index 4361618b..b1f61e83 100644 --- a/packages/web/e2e/queue-persistence.spec.ts +++ b/packages/web/e2e/queue-persistence.spec.ts @@ -4,118 +4,94 @@ import { test, expect, Page } from '@playwright/test'; * E2E tests for queue persistence across navigation. * * These tests verify that the queue state is preserved when navigating - * away from the board page and back. + * away from the board page and back, using the queue bridge architecture. */ -// Helper to wait for the page to be ready +const bottomTabBar = '[data-testid="bottom-tab-bar"]'; +const queueControlBar = '[data-testid="queue-control-bar"]'; + +// Helper to wait for the board page to be ready async function waitForBoardPage(page: Page) { - // Wait for the queue control bar to be visible - await page.waitForSelector('[data-testid="queue-control-bar"]', { timeout: 30000 }).catch(() => { - // Fallback: wait for any content to load - return page.waitForLoadState('networkidle'); - }); + await page + .waitForSelector('#onboarding-climb-card, [data-testid="climb-card"]', { + timeout: 30000, + }) + .catch(() => { + return page.waitForLoadState('networkidle'); + }); } -// Helper to add a climb to the queue -async function addClimbToQueue(page: Page) { - // Click on a climb card to add it to queue - const climbCard = page.locator('[data-testid="climb-card"]').first(); - if (await climbCard.isVisible()) { - await climbCard.click(); - // Wait for the climb to be added - await page.waitForTimeout(500); - } +// Helper to add a climb to the queue via double-click and return the climb name +async function addClimbToQueue(page: Page): Promise { + const climbCard = page.locator('#onboarding-climb-card'); + await expect(climbCard).toBeVisible({ timeout: 15000 }); + await climbCard.dblclick(); + await page.waitForSelector(queueControlBar, { timeout: 10000 }); + + const queueToggle = page.locator('#onboarding-queue-toggle'); + await expect(queueToggle).toBeVisible({ timeout: 5000 }); + const climbName = ((await queueToggle.textContent()) ?? '').trim(); + expect(climbName).toBeTruthy(); + return climbName; +} + +// Helper to verify the queue bar shows the expected climb +async function verifyQueueShowsClimb(page: Page, expectedClimbName: string, timeout = 5000) { + const bar = page.locator(queueControlBar); + await expect(bar).toBeVisible({ timeout: 10000 }); + await expect(bar).toContainText(expectedClimbName, { timeout }); +} + +// Scoped tab button selector to avoid ambiguity with multiple bars during transitions +function bottomTabButton(page: Page, name: string, exact = false) { + return page.locator(bottomTabBar).getByRole('button', { name, exact }); } test.describe('Queue Persistence - Local Mode', () => { - // Use a known board configuration for testing const boardUrl = '/kilter/original/12x12-square/screw_bolt/40/list'; test.beforeEach(async ({ page }) => { - // Navigate to the board page await page.goto(boardUrl); await waitForBoardPage(page); }); - test('queue should persist when navigating to settings and back', async ({ page }) => { - // Get initial queue state - const initialQueueCount = await page.locator('[data-testid="queue-item"]').count(); - - // Add a climb to the queue if none exist - if (initialQueueCount === 0) { - await addClimbToQueue(page); - } - - // Verify queue has items - const queueCountBefore = await page.locator('[data-testid="queue-item"]').count(); - expect(queueCountBefore).toBeGreaterThan(0); + test('queue should persist when navigating to home and back', async ({ page }) => { + const climbName = await addClimbToQueue(page); - // Navigate to settings - await page.goto('/settings'); - await page.waitForLoadState('networkidle'); + // Navigate to home via bottom tab bar (client-side navigation preserves state) + await bottomTabButton(page, 'Home').click(); + await expect(page).toHaveURL('/', { timeout: 15000 }); - // Verify we're on settings page - await expect(page).toHaveURL(/\/settings/); + // Queue bar should show same climb on home page + await verifyQueueShowsClimb(page, climbName); - // Navigate back to the board - await page.goto(boardUrl); - await waitForBoardPage(page); + // Navigate back to the board via bottom tab bar + await bottomTabButton(page, 'Climb', true).click(); + await expect(page).toHaveURL(/\/kilter\//, { timeout: 20000 }); - // Verify queue items are preserved - const queueCountAfter = await page.locator('[data-testid="queue-item"]').count(); - expect(queueCountAfter).toBe(queueCountBefore); + // Verify queue bar still shows same climb after returning + await verifyQueueShowsClimb(page, climbName, 15000); }); - test('global bar should appear when navigating away with queue items', async ({ page }) => { - // Add a climb to the queue - await addClimbToQueue(page); - - // Verify queue has items - const queueCount = await page.locator('[data-testid="queue-item"]').count(); - if (queueCount === 0) { - test.skip(); - return; - } + test('global bar should appear with correct climb when navigating away', async ({ page }) => { + const climbName = await addClimbToQueue(page); - // Navigate to settings (or any non-board page) - await page.goto('/settings'); - await page.waitForLoadState('networkidle'); + // Navigate to home via bottom tab bar (client-side navigation) + await bottomTabButton(page, 'Home').click(); + await expect(page).toHaveURL('/', { timeout: 15000 }); - // Check for queue control bar (same unified component used everywhere) - const globalBar = page.locator('[data-testid="queue-control-bar"]'); - await expect(globalBar).toBeVisible({ timeout: 5000 }); + // Queue control bar should show the same climb on the non-board page + await verifyQueueShowsClimb(page, climbName); }); test('queue control bar should persist correct climb across all pages', async ({ page }) => { - // Wait for the first climb card to render - const climbCard = page.locator('#onboarding-climb-card'); - await expect(climbCard).toBeVisible({ timeout: 15000 }); - - // Double-click the first climb to set it as current - await climbCard.dblclick(); - - // Wait for queue control bar to appear with the climb - const queueBar = page.locator('[data-testid="queue-control-bar"]'); - await expect(queueBar).toBeVisible({ timeout: 10000 }); - - // Capture the climb name from the queue bar - const queueToggle = page.locator('#onboarding-queue-toggle'); - await expect(queueToggle).toBeVisible({ timeout: 5000 }); - const climbName = (await queueToggle.textContent())?.trim(); - expect(climbName).toBeTruthy(); - - // Helper to verify queue bar shows the correct climb on any page - const verifyQueueBarShowsClimb = async (timeout = 5000) => { - const bar = page.locator('[data-testid="queue-control-bar"]'); - await expect(bar).toBeVisible({ timeout: 10000 }); - await expect(bar).toContainText(climbName!, { timeout }); - }; - - // 1. Navigate to Home via bottom tab bar (client-side navigation preserves React state) - // Use last() in case both board-route and root bars briefly coexist during transition - await page.getByRole('button', { name: 'Home' }).last().click(); - await expect(page).toHaveURL(/localhost:3000\/($|\?)/, { timeout: 15000 }); - await verifyQueueBarShowsClimb(); + test.slow(); // This test navigates through 5 pages with queue verification on each + const climbName = await addClimbToQueue(page); + + // 1. Navigate to Home via bottom tab bar + await bottomTabButton(page, 'Home').click(); + await expect(page).toHaveURL('/', { timeout: 15000 }); + await verifyQueueShowsClimb(page, climbName); // 2. Navigate to Settings via user drawer (client-side Link navigation) await page.getByLabel('User menu').click(); @@ -123,80 +99,63 @@ test.describe('Queue Persistence - Local Mode', () => { await expect(settingsLink).toBeVisible({ timeout: 5000 }); // Wait for drawer slide animation to settle before clicking await page.waitForTimeout(500); - await Promise.all([ - page.waitForURL(/\/settings/, { timeout: 15000 }), - settingsLink.click(), - ]); - await verifyQueueBarShowsClimb(); + await Promise.all([page.waitForURL(/\/settings/, { timeout: 15000 }), settingsLink.click()]); + await verifyQueueShowsClimb(page, climbName); // 3. Navigate to Your Library via bottom tab bar - await page.getByRole('button', { name: 'Your Library' }).click(); + await bottomTabButton(page, 'Your Library').click(); await expect(page).toHaveURL(/\/my-library/, { timeout: 15000 }); - await verifyQueueBarShowsClimb(); + await verifyQueueShowsClimb(page, climbName); // 4. Navigate to Notifications via bottom tab bar - await page.getByRole('button', { name: 'Notifications' }).click(); + await bottomTabButton(page, 'Notifications').click(); await expect(page).toHaveURL(/\/notifications/, { timeout: 15000 }); - await verifyQueueBarShowsClimb(); + await verifyQueueShowsClimb(page, climbName); // 5. Navigate back to climb list via bottom tab bar // Longer timeout: board route re-mounts its own queue bar and restores from IndexedDB - await page.getByRole('button', { name: 'Climb', exact: true }).click(); + await bottomTabButton(page, 'Climb', true).click(); await expect(page).toHaveURL(/\/kilter\//, { timeout: 20000 }); - await verifyQueueBarShowsClimb(15000); + await verifyQueueShowsClimb(page, climbName, 15000); }); - test('clicking global bar should navigate back to board', async ({ page }) => { - // Add a climb to the queue - await addClimbToQueue(page); - - // Verify queue has items - const queueCount = await page.locator('[data-testid="queue-item"]').count(); - if (queueCount === 0) { - test.skip(); - return; - } - - // Navigate to settings - await page.goto('/settings'); - await page.waitForLoadState('networkidle'); - - // Click the queue control bar - const globalBar = page.locator('[data-testid="queue-control-bar"]'); - if (await globalBar.isVisible()) { - await globalBar.click(); - - // Verify we're back on a board page - await expect(page).toHaveURL(/\/(kilter|tension)\//); - } + test('clicking global bar thumbnail should navigate back to board', async ({ page }) => { + test.slow(); // Queue setup + navigation + thumbnail click + board route load + const climbName = await addClimbToQueue(page); + + // Navigate to home via bottom tab bar (client-side navigation preserves queue state) + await bottomTabButton(page, 'Home').click(); + await expect(page).toHaveURL('/', { timeout: 15000 }); + + // Verify climb is still shown before clicking + await verifyQueueShowsClimb(page, climbName); + + // Click the thumbnail link within the queue bar (not the bar itself, which opens the play drawer) + const queueBar = page.locator(queueControlBar); + const thumbnailLink = queueBar.locator('a').first(); + await thumbnailLink.click(); + + // Verify we're back on a board page with the same climb + await expect(page).toHaveURL(/\/(kilter|tension)\//, { timeout: 10000 }); + await verifyQueueShowsClimb(page, climbName, 15000); }); }); test.describe('Queue Persistence - Board Switch', () => { - test('queue should clear when switching to different board configuration', async ({ page }) => { + test('queue should persist across angle changes within same board', async ({ page }) => { const boardUrl1 = '/kilter/original/12x12-square/screw_bolt/40/list'; const boardUrl2 = '/kilter/original/12x12-square/screw_bolt/45/list'; // Different angle - // Navigate to first board + // Navigate to first board and add a climb await page.goto(boardUrl1); await waitForBoardPage(page); + const climbName = await addClimbToQueue(page); - // Add a climb to the queue - await addClimbToQueue(page); - - // Get queue count - const queueCountOnBoard1 = await page.locator('[data-testid="queue-item"]').count(); - - // Navigate to different board configuration + // Navigate to different angle await page.goto(boardUrl2); await waitForBoardPage(page); - // Queue should be empty (cleared on board switch) - const queueCountOnBoard2 = await page.locator('[data-testid="queue-item"]').count(); - - // If we had items before, they should be cleared - if (queueCountOnBoard1 > 0) { - expect(queueCountOnBoard2).toBe(0); - } + // Queue bar should still show the same climb (queue bridge persists across angle changes) + await verifyQueueShowsClimb(page, climbName); }); });