From ea05ecce679ba521d1e17b60b52a04648574f3f0 Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Wed, 25 Feb 2026 12:06:00 +0100 Subject: [PATCH 01/13] Fix e2e queue persistence tests and add bottom tab bar e2e tests Fix broken queue-persistence tests for queue bridge architecture: - waitForBoardPage: wait for climb card instead of queue bar - addClimbToQueue: use dblclick (required in list mode) - Replace queue-item count checks with queue bar visibility - Use client-side navigation instead of page.goto for state preservation - Fix global bar click test to use thumbnail link Add comprehensive bottom-tab-bar e2e tests: - Visibility on all pages (home, board, settings, notifications, library) - Navigation between tabs - Active state verification via Mui-selected class - Queue bar + bottom tab bar coexistence Add data-testid attributes to BottomNavigation and bottom bar wrapper. Co-Authored-By: Claude Opus 4.6 --- .../bottom-tab-bar/bottom-tab-bar.tsx | 1 + .../providers/persistent-session-wrapper.tsx | 2 +- packages/web/e2e/bottom-tab-bar.spec.ts | 191 ++++++++++++++++++ packages/web/e2e/queue-persistence.spec.ts | 159 +++++++-------- 4 files changed, 267 insertions(+), 86 deletions(-) create mode 100644 packages/web/e2e/bottom-tab-bar.spec.ts 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/e2e/bottom-tab-bar.spec.ts b/packages/web/e2e/bottom-tab-bar.spec.ts new file mode 100644 index 00000000..cbda0ae1 --- /dev/null +++ b/packages/web/e2e/bottom-tab-bar.spec.ts @@ -0,0 +1,191 @@ +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'; + +async function waitForPageReady(page: Page) { + await page.waitForLoadState('networkidle'); + await expect(page.locator('[data-testid="bottom-tab-bar"]')).toBeVisible({ timeout: 15000 }); +} + +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('[data-testid="bottom-tab-bar"]')).toBeVisible(); + }); + + test('should be visible on a board page', async ({ page }) => { + await page.goto(boardUrl); + await waitForPageReady(page); + await expect(page.locator('[data-testid="bottom-tab-bar"]')).toBeVisible(); + }); + + test('should be visible on the settings page', async ({ page }) => { + await page.goto('/settings'); + await waitForPageReady(page); + await expect(page.locator('[data-testid="bottom-tab-bar"]')).toBeVisible(); + }); + + test('should be visible on the notifications page', async ({ page }) => { + await page.goto('/notifications'); + await waitForPageReady(page); + await expect(page.locator('[data-testid="bottom-tab-bar"]')).toBeVisible(); + }); + + test('should be visible on the my-library page', async ({ page }) => { + await page.goto('/my-library'); + await waitForPageReady(page); + await expect(page.locator('[data-testid="bottom-tab-bar"]')).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 page.getByRole('button', { name: 'Home' }).click(); + await expect(page).toHaveURL(/localhost:3000\/($|\?)/, { timeout: 15000 }); + await expect(page.locator('[data-testid="bottom-tab-bar"]')).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 page.getByRole('button', { name: 'Home' }).click(); + await expect(page).toHaveURL(/localhost:3000\/($|\?)/, { timeout: 15000 }); + + // Now click Climb - should navigate back using last used board + await page.getByRole('button', { name: 'Climb', exact: true }).click(); + await expect(page).toHaveURL(/\/(kilter|tension)\//, { timeout: 15000 }); + await expect(page.locator('[data-testid="bottom-tab-bar"]')).toBeVisible(); + }); + + test('Your Library tab should navigate to my-library page', async ({ page }) => { + await page.goto('/'); + await waitForPageReady(page); + + await page.getByRole('button', { name: 'Your Library' }).click(); + await expect(page).toHaveURL(/\/my-library/, { timeout: 15000 }); + await expect(page.locator('[data-testid="bottom-tab-bar"]')).toBeVisible(); + }); + + test('Notifications tab should navigate to notifications page', async ({ page }) => { + await page.goto('/'); + await waitForPageReady(page); + + await page.getByRole('button', { name: 'Notifications' }).click(); + await expect(page).toHaveURL(/\/notifications/, { timeout: 15000 }); + await expect(page.locator('[data-testid="bottom-tab-bar"]')).toBeVisible(); + }); +}); + +test.describe('Bottom Tab Bar - Active State', () => { + test('Home tab should be active on home page', async ({ page }) => { + await page.goto('/'); + await waitForPageReady(page); + + const homeTab = page.getByRole('button', { name: 'Home' }); + await expect(homeTab).toHaveClass(/Mui-selected/); + }); + + test('Climb tab should be active on board routes', async ({ page }) => { + await page.goto(boardUrl); + await waitForPageReady(page); + + const climbTab = page.getByRole('button', { name: 'Climb', exact: true }); + await expect(climbTab).toHaveClass(/Mui-selected/); + }); + + test('Your Library tab should be active on my-library page', async ({ page }) => { + await page.goto('/my-library'); + await waitForPageReady(page); + + const libraryTab = page.getByRole('button', { name: 'Your Library' }); + await expect(libraryTab).toHaveClass(/Mui-selected/); + }); + + test('Notifications tab should be active on notifications page', async ({ page }) => { + await page.goto('/notifications'); + await waitForPageReady(page); + + const notificationsTab = page.getByRole('button', { name: 'Notifications' }); + await expect(notificationsTab).toHaveClass(/Mui-selected/); + }); +}); + +test.describe('Bottom Tab Bar - Queue Integration', () => { + test('queue bar and bottom tab bar should coexist', 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('[data-testid="queue-control-bar"]')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('[data-testid="bottom-tab-bar"]')).toBeVisible(); + }); + + 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 + const climbCard = page.locator('#onboarding-climb-card'); + await expect(climbCard).toBeVisible({ timeout: 15000 }); + await climbCard.dblclick(); + + // Capture the climb name + await expect(page.locator('[data-testid="queue-control-bar"]')).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(); + + // Navigate to Home + await page.getByRole('button', { name: 'Home' }).last().click(); + await expect(page).toHaveURL(/localhost:3000\/($|\?)/, { timeout: 15000 }); + await expect(page.locator('[data-testid="queue-control-bar"]')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('[data-testid="queue-control-bar"]')).toContainText(climbName); + await expect(page.locator('[data-testid="bottom-tab-bar"]')).toBeVisible(); + + // Navigate to Your Library + await page.getByRole('button', { name: 'Your Library' }).click(); + await expect(page).toHaveURL(/\/my-library/, { timeout: 15000 }); + await expect(page.locator('[data-testid="queue-control-bar"]')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('[data-testid="queue-control-bar"]')).toContainText(climbName); + await expect(page.locator('[data-testid="bottom-tab-bar"]')).toBeVisible(); + + // Navigate to Notifications + await page.getByRole('button', { name: 'Notifications' }).click(); + await expect(page).toHaveURL(/\/notifications/, { timeout: 15000 }); + await expect(page.locator('[data-testid="queue-control-bar"]')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('[data-testid="queue-control-bar"]')).toContainText(climbName); + await expect(page.locator('[data-testid="bottom-tab-bar"]')).toBeVisible(); + + // Navigate back to Climb + await page.getByRole('button', { name: 'Climb', exact: true }).click(); + await expect(page).toHaveURL(/\/kilter\//, { timeout: 20000 }); + await expect(page.locator('[data-testid="queue-control-bar"]')).toBeVisible({ timeout: 15000 }); + await expect(page.locator('[data-testid="queue-control-bar"]')).toContainText(climbName, { timeout: 15000 }); + await expect(page.locator('[data-testid="bottom-tab-bar"]')).toBeVisible(); + }); +}); diff --git a/packages/web/e2e/queue-persistence.spec.ts b/packages/web/e2e/queue-persistence.spec.ts index 4361618b..0756d973 100644 --- a/packages/web/e2e/queue-persistence.spec.ts +++ b/packages/web/e2e/queue-persistence.spec.ts @@ -4,84 +4,86 @@ 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 +// 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 +// Helper to add a climb to the queue via double-click (list mode requires double-click) 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); - } + const climbCard = page.locator('#onboarding-climb-card'); + await expect(climbCard).toBeVisible({ timeout: 15000 }); + await climbCard.dblclick(); + await page.waitForSelector('[data-testid="queue-control-bar"]', { timeout: 10000 }); +} + +// Helper to verify the queue bar is visible (meaning queue has items) +async function verifyQueueHasItems(page: Page) { + await expect(page.locator('[data-testid="queue-control-bar"]')).toBeVisible({ timeout: 10000 }); +} + +// Helper to get the current climb name from the queue toggle button +async function getQueueClimbName(page: Page): Promise { + const queueToggle = page.locator('#onboarding-queue-toggle'); + await expect(queueToggle).toBeVisible({ timeout: 5000 }); + return ((await queueToggle.textContent()) ?? '').trim(); } 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); - } + test('queue should persist when navigating to home and back', async ({ page }) => { + // Add a climb to the queue + await addClimbToQueue(page); - // Verify queue has items - const queueCountBefore = await page.locator('[data-testid="queue-item"]').count(); - expect(queueCountBefore).toBeGreaterThan(0); + // Verify queue bar is visible and capture the climb name + await verifyQueueHasItems(page); + const climbNameBefore = await getQueueClimbName(page); + expect(climbNameBefore).toBeTruthy(); - // Navigate to settings - await page.goto('/settings'); - await page.waitForLoadState('networkidle'); + // Navigate to home via bottom tab bar (client-side navigation preserves state) + await page.getByRole('button', { name: 'Home' }).last().click(); + await expect(page).toHaveURL(/localhost:3000\/($|\?)/, { timeout: 15000 }); - // Verify we're on settings page - await expect(page).toHaveURL(/\/settings/); + // Queue bar should still be visible on home page + await verifyQueueHasItems(page); - // Navigate back to the board - await page.goto(boardUrl); - await waitForBoardPage(page); + // Navigate back to the board via bottom tab bar + await page.getByRole('button', { name: 'Climb', exact: 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 is still visible with the same climb + await verifyQueueHasItems(page); + const climbNameAfter = await getQueueClimbName(page); + expect(climbNameAfter).toBe(climbNameBefore); }); 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; - } + // Verify queue bar is visible + await verifyQueueHasItems(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 page.getByRole('button', { name: 'Home' }).last().click(); + await expect(page).toHaveURL(/localhost:3000\/($|\?)/, { timeout: 15000 }); - // Check for queue control bar (same unified component used everywhere) + // Check for queue control bar on the non-board page const globalBar = page.locator('[data-testid="queue-control-bar"]'); await expect(globalBar).toBeVisible({ timeout: 5000 }); }); @@ -112,7 +114,6 @@ test.describe('Queue Persistence - Local Mode', () => { }; // 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(); @@ -123,10 +124,7 @@ 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 Promise.all([page.waitForURL(/\/settings/, { timeout: 15000 }), settingsLink.click()]); await verifyQueueBarShowsClimb(); // 3. Navigate to Your Library via bottom tab bar @@ -146,57 +144,48 @@ test.describe('Queue Persistence - Local Mode', () => { await verifyQueueBarShowsClimb(15000); }); - test('clicking global bar should navigate back to board', async ({ page }) => { + test('clicking global bar thumbnail 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; - } + // Verify queue bar is visible + await verifyQueueHasItems(page); - // Navigate to settings - await page.goto('/settings'); - await page.waitForLoadState('networkidle'); + // Navigate to home via bottom tab bar (client-side navigation preserves queue state) + await page.getByRole('button', { name: 'Home' }).last().click(); + await expect(page).toHaveURL(/localhost:3000\/($|\?)/, { timeout: 15000 }); - // Click the queue control bar - const globalBar = page.locator('[data-testid="queue-control-bar"]'); - if (await globalBar.isVisible()) { - await globalBar.click(); + // Click the thumbnail link within the queue bar (not the bar itself, which opens the play drawer) + const queueBar = page.locator('[data-testid="queue-control-bar"]'); + await expect(queueBar).toBeVisible({ timeout: 5000 }); + const thumbnailLink = queueBar.locator('a').first(); + await thumbnailLink.click(); - // Verify we're back on a board page - await expect(page).toHaveURL(/\/(kilter|tension)\//); - } + // Verify we're back on a board page + await expect(page).toHaveURL(/\/(kilter|tension)\//, { timeout: 10000 }); }); }); 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); - - // Add a climb to the queue await addClimbToQueue(page); - // Get queue count - const queueCountOnBoard1 = await page.locator('[data-testid="queue-item"]').count(); + // Verify queue bar is visible and capture climb name + await verifyQueueHasItems(page); + const climbName = await getQueueClimbName(page); + expect(climbName).toBeTruthy(); - // 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 be visible (queue bridge persists across angle changes) + await verifyQueueHasItems(page); }); }); From c86cf7c6115bffa931573b6fe319adf66df3a256 Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Wed, 25 Feb 2026 12:21:45 +0100 Subject: [PATCH 02/13] Address review feedback on e2e tests - Strengthen climb name assertions: all tests now capture the climb name and verify it stays correct after each navigation, not just bar visibility - Fix hardcoded localhost: replace /localhost:3000\/($|\?)/ with toHaveURL('/') using Playwright's baseURL-relative matching - Fix fragile .last() selector: scope Home button (and all tab buttons) to [data-testid="bottom-tab-bar"] via bottomTabButton() helper - Keep Mui-selected class for active state (MUI BottomNavigationAction does not render aria-selected); added comment explaining why - Add test.slow() for multi-page navigation tests that exceed 30s default Co-Authored-By: Claude Opus 4.6 --- packages/web/e2e/bottom-tab-bar.spec.ts | 106 +++++++------- packages/web/e2e/queue-persistence.spec.ts | 152 +++++++++------------ 2 files changed, 119 insertions(+), 139 deletions(-) diff --git a/packages/web/e2e/bottom-tab-bar.spec.ts b/packages/web/e2e/bottom-tab-bar.spec.ts index cbda0ae1..c7f4d542 100644 --- a/packages/web/e2e/bottom-tab-bar.spec.ts +++ b/packages/web/e2e/bottom-tab-bar.spec.ts @@ -9,41 +9,48 @@ import { test, expect, Page } from '@playwright/test'; */ 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('[data-testid="bottom-tab-bar"]')).toBeVisible({ timeout: 15000 }); + 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('[data-testid="bottom-tab-bar"]')).toBeVisible(); + 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('[data-testid="bottom-tab-bar"]')).toBeVisible(); + 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('[data-testid="bottom-tab-bar"]')).toBeVisible(); + 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('[data-testid="bottom-tab-bar"]')).toBeVisible(); + 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('[data-testid="bottom-tab-bar"]')).toBeVisible(); + await expect(page.locator(bottomTabBar)).toBeVisible(); }); }); @@ -52,9 +59,9 @@ test.describe('Bottom Tab Bar - Navigation', () => { await page.goto(boardUrl); await waitForPageReady(page); - await page.getByRole('button', { name: 'Home' }).click(); - await expect(page).toHaveURL(/localhost:3000\/($|\?)/, { timeout: 15000 }); - await expect(page.locator('[data-testid="bottom-tab-bar"]')).toBeVisible(); + 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 }) => { @@ -63,70 +70,68 @@ test.describe('Bottom Tab Bar - Navigation', () => { await waitForPageReady(page); // Navigate to home - await page.getByRole('button', { name: 'Home' }).click(); - await expect(page).toHaveURL(/localhost:3000\/($|\?)/, { timeout: 15000 }); + await bottomTabButton(page, 'Home').click(); + await expect(page).toHaveURL('/', { timeout: 15000 }); // Now click Climb - should navigate back using last used board - await page.getByRole('button', { name: 'Climb', exact: true }).click(); + await bottomTabButton(page, 'Climb', true).click(); await expect(page).toHaveURL(/\/(kilter|tension)\//, { timeout: 15000 }); - await expect(page.locator('[data-testid="bottom-tab-bar"]')).toBeVisible(); + 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 page.getByRole('button', { name: 'Your Library' }).click(); + await bottomTabButton(page, 'Your Library').click(); await expect(page).toHaveURL(/\/my-library/, { timeout: 15000 }); - await expect(page.locator('[data-testid="bottom-tab-bar"]')).toBeVisible(); + await expect(page.locator(bottomTabBar)).toBeVisible(); }); test('Notifications tab should navigate to notifications page', async ({ page }) => { await page.goto('/'); await waitForPageReady(page); - await page.getByRole('button', { name: 'Notifications' }).click(); + await bottomTabButton(page, 'Notifications').click(); await expect(page).toHaveURL(/\/notifications/, { timeout: 15000 }); - await expect(page.locator('[data-testid="bottom-tab-bar"]')).toBeVisible(); + 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); - const homeTab = page.getByRole('button', { name: 'Home' }); - await expect(homeTab).toHaveClass(/Mui-selected/); + 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); - const climbTab = page.getByRole('button', { name: 'Climb', exact: true }); - await expect(climbTab).toHaveClass(/Mui-selected/); + 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); - const libraryTab = page.getByRole('button', { name: 'Your Library' }); - await expect(libraryTab).toHaveClass(/Mui-selected/); + 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); - const notificationsTab = page.getByRole('button', { name: 'Notifications' }); - await expect(notificationsTab).toHaveClass(/Mui-selected/); + await expect(bottomTabButton(page, 'Notifications')).toHaveClass(/Mui-selected/); }); }); test.describe('Bottom Tab Bar - Queue Integration', () => { - test('queue bar and bottom tab bar should coexist', async ({ page }) => { + 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 }) @@ -138,8 +143,15 @@ test.describe('Bottom Tab Bar - Queue Integration', () => { await climbCard.dblclick(); // Both bars should be visible - await expect(page.locator('[data-testid="queue-control-bar"]')).toBeVisible({ timeout: 10000 }); - await expect(page.locator('[data-testid="bottom-tab-bar"]')).toBeVisible(); + 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 }) => { @@ -148,44 +160,42 @@ test.describe('Bottom Tab Bar - Queue Integration', () => { .waitForSelector('#onboarding-climb-card, [data-testid="climb-card"]', { timeout: 30000 }) .catch(() => page.waitForLoadState('networkidle')); - // Add a climb to the queue + // 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(); - // Capture the climb name - await expect(page.locator('[data-testid="queue-control-bar"]')).toBeVisible({ timeout: 10000 }); + 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 page.getByRole('button', { name: 'Home' }).last().click(); - await expect(page).toHaveURL(/localhost:3000\/($|\?)/, { timeout: 15000 }); - await expect(page.locator('[data-testid="queue-control-bar"]')).toBeVisible({ timeout: 10000 }); - await expect(page.locator('[data-testid="queue-control-bar"]')).toContainText(climbName); - await expect(page.locator('[data-testid="bottom-tab-bar"]')).toBeVisible(); + await bottomTabButton(page, 'Home').click(); + await expect(page).toHaveURL('/', { timeout: 15000 }); + await verifyBarsShowClimb(); // Navigate to Your Library - await page.getByRole('button', { name: 'Your Library' }).click(); + await bottomTabButton(page, 'Your Library').click(); await expect(page).toHaveURL(/\/my-library/, { timeout: 15000 }); - await expect(page.locator('[data-testid="queue-control-bar"]')).toBeVisible({ timeout: 10000 }); - await expect(page.locator('[data-testid="queue-control-bar"]')).toContainText(climbName); - await expect(page.locator('[data-testid="bottom-tab-bar"]')).toBeVisible(); + await verifyBarsShowClimb(); // Navigate to Notifications - await page.getByRole('button', { name: 'Notifications' }).click(); + await bottomTabButton(page, 'Notifications').click(); await expect(page).toHaveURL(/\/notifications/, { timeout: 15000 }); - await expect(page.locator('[data-testid="queue-control-bar"]')).toBeVisible({ timeout: 10000 }); - await expect(page.locator('[data-testid="queue-control-bar"]')).toContainText(climbName); - await expect(page.locator('[data-testid="bottom-tab-bar"]')).toBeVisible(); + await verifyBarsShowClimb(); // Navigate back to Climb - await page.getByRole('button', { name: 'Climb', exact: true }).click(); + await bottomTabButton(page, 'Climb', true).click(); await expect(page).toHaveURL(/\/kilter\//, { timeout: 20000 }); - await expect(page.locator('[data-testid="queue-control-bar"]')).toBeVisible({ timeout: 15000 }); - await expect(page.locator('[data-testid="queue-control-bar"]')).toContainText(climbName, { timeout: 15000 }); - await expect(page.locator('[data-testid="bottom-tab-bar"]')).toBeVisible(); + await verifyBarsShowClimb(15000); }); }); diff --git a/packages/web/e2e/queue-persistence.spec.ts b/packages/web/e2e/queue-persistence.spec.ts index 0756d973..b1f61e83 100644 --- a/packages/web/e2e/queue-persistence.spec.ts +++ b/packages/web/e2e/queue-persistence.spec.ts @@ -7,6 +7,9 @@ import { test, expect, Page } from '@playwright/test'; * away from the board page and back, using the queue bridge architecture. */ +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) { await page @@ -18,24 +21,30 @@ async function waitForBoardPage(page: Page) { }); } -// Helper to add a climb to the queue via double-click (list mode requires double-click) -async function addClimbToQueue(page: Page) { +// 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('[data-testid="queue-control-bar"]', { timeout: 10000 }); + 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 is visible (meaning queue has items) -async function verifyQueueHasItems(page: Page) { - await expect(page.locator('[data-testid="queue-control-bar"]')).toBeVisible({ timeout: 10000 }); +// 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 }); } -// Helper to get the current climb name from the queue toggle button -async function getQueueClimbName(page: Page): Promise { - const queueToggle = page.locator('#onboarding-queue-toggle'); - await expect(queueToggle).toBeVisible({ timeout: 5000 }); - return ((await queueToggle.textContent()) ?? '').trim(); +// 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', () => { @@ -47,76 +56,42 @@ test.describe('Queue Persistence - Local Mode', () => { }); test('queue should persist when navigating to home and back', async ({ page }) => { - // Add a climb to the queue - await addClimbToQueue(page); - - // Verify queue bar is visible and capture the climb name - await verifyQueueHasItems(page); - const climbNameBefore = await getQueueClimbName(page); - expect(climbNameBefore).toBeTruthy(); + const climbName = await addClimbToQueue(page); // Navigate to home via bottom tab bar (client-side navigation preserves state) - await page.getByRole('button', { name: 'Home' }).last().click(); - await expect(page).toHaveURL(/localhost:3000\/($|\?)/, { timeout: 15000 }); + await bottomTabButton(page, 'Home').click(); + await expect(page).toHaveURL('/', { timeout: 15000 }); - // Queue bar should still be visible on home page - await verifyQueueHasItems(page); + // Queue bar should show same climb on home page + await verifyQueueShowsClimb(page, climbName); // Navigate back to the board via bottom tab bar - await page.getByRole('button', { name: 'Climb', exact: true }).click(); + await bottomTabButton(page, 'Climb', true).click(); await expect(page).toHaveURL(/\/kilter\//, { timeout: 20000 }); - // Verify queue bar is still visible with the same climb - await verifyQueueHasItems(page); - const climbNameAfter = await getQueueClimbName(page); - expect(climbNameAfter).toBe(climbNameBefore); + // 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 bar is visible - await verifyQueueHasItems(page); + test('global bar should appear with correct climb when navigating away', async ({ page }) => { + const climbName = await addClimbToQueue(page); // Navigate to home via bottom tab bar (client-side navigation) - await page.getByRole('button', { name: 'Home' }).last().click(); - await expect(page).toHaveURL(/localhost:3000\/($|\?)/, { timeout: 15000 }); + await bottomTabButton(page, 'Home').click(); + await expect(page).toHaveURL('/', { timeout: 15000 }); - // Check for queue control bar on the non-board page - 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) - 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(); @@ -125,44 +100,44 @@ test.describe('Queue Persistence - Local Mode', () => { // 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 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 thumbnail should navigate back to board', async ({ page }) => { - // Add a climb to the queue - await addClimbToQueue(page); - - // Verify queue bar is visible - await verifyQueueHasItems(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 page.getByRole('button', { name: 'Home' }).last().click(); - await expect(page).toHaveURL(/localhost:3000\/($|\?)/, { timeout: 15000 }); + 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('[data-testid="queue-control-bar"]'); - await expect(queueBar).toBeVisible({ timeout: 5000 }); + const queueBar = page.locator(queueControlBar); const thumbnailLink = queueBar.locator('a').first(); await thumbnailLink.click(); - // Verify we're back on a board page + // 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); }); }); @@ -174,18 +149,13 @@ test.describe('Queue Persistence - Board Switch', () => { // Navigate to first board and add a climb await page.goto(boardUrl1); await waitForBoardPage(page); - await addClimbToQueue(page); - - // Verify queue bar is visible and capture climb name - await verifyQueueHasItems(page); - const climbName = await getQueueClimbName(page); - expect(climbName).toBeTruthy(); + const climbName = await addClimbToQueue(page); // Navigate to different angle await page.goto(boardUrl2); await waitForBoardPage(page); - // Queue bar should still be visible (queue bridge persists across angle changes) - await verifyQueueHasItems(page); + // Queue bar should still show the same climb (queue bridge persists across angle changes) + await verifyQueueShowsClimb(page, climbName); }); }); From a39949ee635dcb3c9882f0eef601c3a13d41eabc Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Wed, 25 Feb 2026 12:23:26 +0100 Subject: [PATCH 03/13] Remove branches filter from pull_request workflow triggers The branches filter on pull_request matches the base branch, which prevented workflows from running on PRs targeting feature branches (e.g. better_global_queue_control_bar). Paths filtering alone is sufficient to scope when workflows run. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/e2e-tests.yml | 1 - .github/workflows/moonboard-ocr-tests.yml | 1 - .github/workflows/test.yml | 1 - 3 files changed, 3 deletions(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index a40123db..9c33995b 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -9,7 +9,6 @@ on: - 'packages/db/**' - '.github/workflows/e2e-tests.yml' pull_request: - branches: [ main, develop ] paths: - 'packages/web/**' - 'packages/shared-schema/**' 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/**' From 9e3b6678fff7bb95684ca4029f594c4825296156 Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Wed, 25 Feb 2026 12:30:18 +0100 Subject: [PATCH 04/13] Fix e2e workflow: use dev-db image and correct migration directory The e2e workflow was failing because it used a bare PostgreSQL image without board data and ran drizzle-kit migrate from packages/web/ which has no drizzle config. Switch to the pre-built dev-db image and run migrations from packages/db/ where drizzle.config.ts lives. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/e2e-tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 9c33995b..9261fbe2 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -22,7 +22,7 @@ jobs: 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 @@ -67,7 +67,7 @@ jobs: - 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 From 22e24c98e7fe54c6e523fff896563981c3adccb7 Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Wed, 25 Feb 2026 12:36:45 +0100 Subject: [PATCH 05/13] Fix database credentials to match pre-built dev-db image The dev-db image has data baked into the 'main' database with password 'password'. POSTGRES_DB is ignored when the image already has an initialized data directory, so the migration was failing with "database boardsesh_test does not exist". Co-Authored-By: Claude Opus 4.6 --- .github/workflows/e2e-tests.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 9261fbe2..fba54f6b 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -25,8 +25,8 @@ jobs: image: ghcr.io/marcodejongh/boardsesh-dev-db:latest env: POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: boardsesh_test + POSTGRES_PASSWORD: password + POSTGRES_DB: main ports: - 5432:5432 options: >- @@ -63,13 +63,13 @@ 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@localhost:5432/main - name: Run database migrations run: npx drizzle-kit migrate working-directory: packages/db env: - DATABASE_URL: postgresql://postgres:postgres@localhost:5432/boardsesh_test + DATABASE_URL: postgresql://postgres:password@localhost:5432/main - name: Start server and run E2E tests run: | @@ -78,7 +78,7 @@ jobs: 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@localhost:5432/main NEXTAUTH_SECRET: test-secret-for-ci NEXTAUTH_URL: http://localhost:3000 From d09b9d928a6c1bf26f0ab4b56a6ba71eb5bd116c Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Wed, 25 Feb 2026 12:36:49 +0100 Subject: [PATCH 06/13] Fix help screenshot e2e tests and update FAQ content for current UI Rewrite beforeEach to use direct URL navigation instead of obsolete combobox board selection. Update all test selectors to match the redesigned UI (search drawer, queue bar, user drawer). Update FAQ text in help-content.tsx to reference current UI flows. Co-Authored-By: Claude Opus 4.6 --- .../components/board-page/share-button.tsx | 1 + packages/web/app/help/help-content.tsx | 12 +- packages/web/e2e/help-screenshots.spec.ts | 148 +++++++++--------- 3 files changed, 80 insertions(+), 81 deletions(-) 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/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/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` }); From 5f96a00f8bffcae5b00e2676ddf5b9f9d5d019da Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Wed, 25 Feb 2026 12:42:50 +0100 Subject: [PATCH 07/13] Add Neon HTTP proxy service to e2e workflow The Next.js app uses the Neon serverless driver which connects via HTTP fetch through a proxy. Added the local-neon-http-proxy service and set the runtime DATABASE_URL to db.localtest.me so the Neon config routes queries through the proxy at localhost:4444. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/e2e-tests.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index fba54f6b..edb8d500 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -35,6 +35,14 @@ jobs: --health-timeout 5s --health-retries 5 + neon-proxy: + image: ghcr.io/timowilhelm/local-neon-http-proxy:main + env: + PG_CONNECTION_STRING: postgres://postgres:password@postgres:5432/main + POSTGRES_DB: main + ports: + - 4444:4444 + steps: - uses: actions/checkout@v4 @@ -78,7 +86,7 @@ jobs: npm run test:e2e --workspace=@boardsesh/web env: PLAYWRIGHT_TEST_BASE_URL: http://localhost:3000 - DATABASE_URL: postgresql://postgres:password@localhost:5432/main + DATABASE_URL: postgresql://postgres:password@db.localtest.me:5432/main NEXTAUTH_SECRET: test-secret-for-ci NEXTAUTH_URL: http://localhost:3000 From 7715044a8d46245d27ee8de874ae37b95045c50d Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Wed, 25 Feb 2026 12:44:48 +0100 Subject: [PATCH 08/13] Use consistent db.localtest.me hostname in all DATABASE_URL refs Co-Authored-By: Claude Opus 4.6 --- .github/workflows/e2e-tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index edb8d500..db51f383 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -71,13 +71,13 @@ jobs: # Provide minimal env vars needed for build NEXTAUTH_SECRET: test-secret-for-ci NEXTAUTH_URL: http://localhost:3000 - DATABASE_URL: postgresql://postgres:password@localhost:5432/main + DATABASE_URL: postgresql://postgres:password@db.localtest.me:5432/main - name: Run database migrations run: npx drizzle-kit migrate working-directory: packages/db env: - DATABASE_URL: postgresql://postgres:password@localhost:5432/main + DATABASE_URL: postgresql://postgres:password@db.localtest.me:5432/main - name: Start server and run E2E tests run: | From 16bae8d037a07181f9c4767367cb67f5ef9afa03 Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Wed, 25 Feb 2026 12:52:15 +0100 Subject: [PATCH 09/13] Add backend service to e2e workflow for SSR GraphQL queries The SSR climb search makes GraphQL HTTP requests to the backend at port 8080. Without the backend running, pages degrade to empty results and tests fail waiting for climb cards. - Build and start the backend before the web server - Set NEXT_PUBLIC_WS_URL at build time so it gets baked into the bundle - Wait for backend /health endpoint before starting the web server - Add packages/backend/** to workflow path triggers - Redis is optional; the backend gracefully degrades without it Co-Authored-By: Claude Opus 4.6 --- .github/workflows/e2e-tests.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index db51f383..3bc7dbe4 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -5,12 +5,14 @@ on: branches: [ main, develop ] paths: - 'packages/web/**' + - 'packages/backend/**' - 'packages/shared-schema/**' - 'packages/db/**' - '.github/workflows/e2e-tests.yml' pull_request: paths: - 'packages/web/**' + - 'packages/backend/**' - 'packages/shared-schema/**' - 'packages/db/**' - '.github/workflows/e2e-tests.yml' @@ -65,6 +67,9 @@ jobs: 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 env: @@ -72,6 +77,7 @@ jobs: NEXTAUTH_SECRET: test-secret-for-ci NEXTAUTH_URL: http://localhost:3000 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 @@ -79,8 +85,10 @@ jobs: env: DATABASE_URL: postgresql://postgres:password@db.localtest.me: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://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 @@ -89,6 +97,7 @@ jobs: 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 - name: Upload Playwright Report uses: actions/upload-artifact@v4 From 51d9b762f312a8b8609b41386d2e9a1d1b4a895c Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Wed, 25 Feb 2026 13:01:49 +0100 Subject: [PATCH 10/13] Use Playwright Docker image to skip browser install step Switch from installing Playwright browsers (5+ min) to running the job inside mcr.microsoft.com/playwright:v1.57.0-noble which has Chromium pre-installed. Since container jobs access services by hostname instead of localhost, add an /etc/hosts entry mapping db.localtest.me to the neon-proxy service IP so the Neon driver's fetch endpoint reaches the proxy. Migration step uses postgres hostname directly (pg driver). Co-Authored-By: Claude Opus 4.6 --- .github/workflows/e2e-tests.yml | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 3bc7dbe4..81e661d4 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -21,6 +21,8 @@ jobs: e2e: timeout-minutes: 30 runs-on: ubuntu-latest + container: + image: mcr.microsoft.com/playwright:v1.57.0-noble services: postgres: @@ -29,8 +31,6 @@ jobs: POSTGRES_USER: postgres POSTGRES_PASSWORD: password POSTGRES_DB: main - ports: - - 5432:5432 options: >- --health-cmd pg_isready --health-interval 10s @@ -42,17 +42,22 @@ jobs: env: PG_CONNECTION_STRING: postgres://postgres:password@postgres:5432/main POSTGRES_DB: main - ports: - - 4444:4444 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 @@ -63,10 +68,6 @@ 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 @@ -83,7 +84,7 @@ jobs: run: npx drizzle-kit migrate working-directory: packages/db env: - DATABASE_URL: postgresql://postgres:password@db.localtest.me:5432/main + DATABASE_URL: postgresql://postgres:password@postgres:5432/main - name: Start backend and web server, then run E2E tests run: | From a8b10bcaa40de949a32a7036d1e7da50aa24dd32 Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Wed, 25 Feb 2026 13:07:26 +0100 Subject: [PATCH 11/13] Fix wait-on to use GET for backend health check The /health endpoint only responds to GET requests, but wait-on defaults to HEAD for http:// URLs. Use http-get:// prefix to force GET requests. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/e2e-tests.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 81e661d4..bf882715 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -89,7 +89,7 @@ jobs: - name: Start backend and web server, then run E2E tests run: | npm run start --workspace=boardsesh-backend & - npx wait-on http://localhost:8080/health --timeout 30000 + 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 @@ -99,6 +99,8 @@ jobs: 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 From 0c41e19c98f2fbfd16d0b01ce5a4995c8e7fd35c Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Wed, 25 Feb 2026 13:11:29 +0100 Subject: [PATCH 12/13] Increase PostgreSQL shared memory to 256mb in e2e workflow The dev-db image needs more shared memory than the default 64mb. Queries were failing with "could not resize shared memory segment: No space left on device". Matches the shm_size in docker-compose.yml. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/e2e-tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index bf882715..1a215827 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -36,6 +36,7 @@ jobs: --health-interval 10s --health-timeout 5s --health-retries 5 + --shm-size 256mb neon-proxy: image: ghcr.io/timowilhelm/local-neon-http-proxy:main From 6e29f93440a32c5707820d3cd9d11e1472daad31 Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Wed, 25 Feb 2026 13:14:23 +0100 Subject: [PATCH 13/13] Fix activity feed race condition that flashes empty state The activity feed at `/` sporadically flashed items then showed "Follow climbers to see their activity here" even for users with follows. This was caused by two interacting race conditions: 1. `useSession()` status `'loading'` was treated as unauthenticated, firing a trending fetch before auth resolved. 2. No mechanism to discard stale in-flight fetch results, so the trending and authenticated fetches would race and overwrite each other. Fix: add `authSessionLoading` prop to gate fetching until session resolves, and a monotonic fetch ID ref to discard stale responses. Includes unit tests (7 cases) and e2e tests for regression. Co-Authored-By: Claude Opus 4.6 --- .../__tests__/activity-feed.test.tsx | 260 ++++++++++++++++++ .../activity-feed/activity-feed.tsx | 66 +++-- packages/web/app/home-page-content.tsx | 2 + packages/web/e2e/activity-feed.spec.ts | 128 +++++++++ 4 files changed, 435 insertions(+), 21 deletions(-) create mode 100644 packages/web/app/components/activity-feed/__tests__/activity-feed.test.tsx create mode 100644 packages/web/e2e/activity-feed.spec.ts 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/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(); + }); +});