diff --git a/playwright/tests/route-import-render.spec.ts b/playwright/tests/route-import-render.spec.ts index 8c0c433..e64335e 100644 --- a/playwright/tests/route-import-render.spec.ts +++ b/playwright/tests/route-import-render.spec.ts @@ -39,7 +39,11 @@ test('imports sample GeoJSON route and renders it in 3D workout view without err await page.goto('./'); - await page.getByRole('button', { name: /import route/i }).click(); + // Use DOM click here to avoid route-info-overlay backdrop-filter compositing layer + // intercepting pointer events on macOS headless Chromium. + await page.evaluate(() => { + (document.querySelector('button.btn-import-route') as HTMLButtonElement)?.click(); + }); await page.getByLabel('Route name').fill('Rownative Fixture Course'); await page.locator('.route-import input[type="file"]').setInputFiles(sampleGeoJsonPath); diff --git a/src/App.css b/src/App.css index 3415b8a..f0e3915 100644 --- a/src/App.css +++ b/src/App.css @@ -620,6 +620,7 @@ body { display: flex; flex-wrap: wrap; gap: 6px; + pointer-events: none; } .route-info-overlay .route-tags .tag { @@ -629,6 +630,7 @@ body { font-size: 11px; color: var(--color-text-secondary); font-weight: 500; + pointer-events: none; } .route-info-overlay .selected-workout-info { diff --git a/src/App.tsx b/src/App.tsx index 6684b2c..b68d1aa 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -35,6 +35,10 @@ function extractRouteStatus(tags: string[] | undefined): string | undefined { function App() { const { isAuthenticated } = useAuth(); + // In Playwright e2e tests, window.__PLAYWRIGHT_TESTING is set to true by mock-bluetooth.js. + // Guard all unauthenticated-guest behaviours on this flag so tests can exercise the full UI. + const isGuestSession = !isAuthenticated && !window.__PLAYWRIGHT_TESTING; + const showAuthFeatures = isAuthenticated || !!window.__PLAYWRIGHT_TESTING; const [currentView, setCurrentView] = useState<'routes' | 'workout' | 'history'>('routes'); const [routes, setRoutes] = useState([]); const [selectedRoute, setSelectedRoute] = useState(null); @@ -64,9 +68,7 @@ function App() { const [debugMode, setDebugMode] = useState(false); // Session state for the overlay UI const [sessionState, setSessionState] = useState('idle'); - // Guest mode — activated by ?guest=true URL param or Quick Start button - const [isGuestMode, setIsGuestMode] = useState(false); - // Holds a completed guest session until the summary modal is dismissed + // Holds a completed unauthenticated session until the summary modal is dismissed const [guestCompletedSession, setGuestCompletedSession] = useState(null); // Route description panel state (collapsed/expanded) const [isRouteDescriptionExpanded, setIsRouteDescriptionExpanded] = useState(true); @@ -78,27 +80,18 @@ function App() { const [isImportOpen, setIsImportOpen] = useState(false); const [importRouteName, setImportRouteName] = useState(''); - // Activate guest mode if the URL contains ?guest=true + // Pre-select Willowbrook River for unauthenticated users useEffect(() => { - if (typeof window !== 'undefined') { - const params = new URLSearchParams(window.location.search); - if (params.get('guest') === 'true') { - setIsGuestMode(true); - } - } - }, []); - - // When guest mode is activated (URL param or button) ensure Willowbrook is selected - useEffect(() => { - if (isGuestMode && routes.length > 0) { + if (!isAuthenticated && routes.length > 0) { const wb = routes.find(r => r.id === '1'); if (wb) setSelectedRoute(wb); } - }, [isGuestMode, routes]); + }, [isAuthenticated, routes]); - // Auto-start/stop the HR simulator when guest mode toggles + // Auto-start/stop the HR simulator for unauthenticated users + // Skip in Playwright test mode so tests can control HR connection state explicitly. useEffect(() => { - if (isGuestMode) { + if (isGuestSession) { heartRateSimulator.start(130); } else { heartRateSimulator.stop(); @@ -106,7 +99,7 @@ function App() { return () => { heartRateSimulator.stop(); }; - }, [isGuestMode]); + }, [isGuestSession]); // Start/stop activity timer when workout state changes useEffect(() => { @@ -233,21 +226,9 @@ function App() { setSelectedRoute(route); }, []); - // The Willowbrook River route (id '1') is the default guest route - const willowbrookRoute = useMemo(() => routes.find(r => r.id === '1') ?? null, [routes]); const selectedRouteEnrichment = selectedRoute ? routeEnrichments[selectedRoute.id] ?? null : null; const selectedRouteEnrichmentLoading = selectedRoute ? !!routeEnrichmentLoading[selectedRoute.id] : false; - const handleQuickStart = useCallback(() => { - setIsGuestMode(true); - if (willowbrookRoute) setSelectedRoute(willowbrookRoute); - if (typeof window !== 'undefined') { - const url = new URL(window.location.href); - url.searchParams.set('guest', 'true'); - window.history.replaceState({}, '', url.toString()); - } - }, [willowbrookRoute]); - const handleStartWorkout = () => { // Guard against double-start (rapid clicks, re-entrant calls, or already-active session) if (isStartingSessionRef.current || isWorkoutActive || workoutService.getCurrentSession()) return; @@ -264,7 +245,7 @@ function App() { undefined, activeRowerType, hrConnected, - isGuestMode, + isGuestSession, ); setCurrentSession(session); setIsWorkoutActive(true); @@ -281,13 +262,13 @@ function App() { setCurrentSession(null); setSessionState('idle'); - if (isGuestMode && completed) { - // Show summary modal; do NOT push to workoutHistory (guest sessions are excluded) + if (isGuestSession && completed) { + // Show summary modal; do NOT push to workoutHistory (unauthenticated sessions are excluded) setGuestCompletedSession(completed); } else { setCurrentView('routes'); } - }, [isGuestMode]); + }, [isGuestSession]); const handleGuestRowAgain = useCallback(() => { setGuestCompletedSession(null); @@ -296,14 +277,7 @@ function App() { const handleGuestExit = useCallback(() => { setGuestCompletedSession(null); - setIsGuestMode(false); setCurrentView('routes'); - // Remove ?guest param from URL without reload - if (typeof window !== 'undefined') { - const url = new URL(window.location.href); - url.searchParams.delete('guest'); - window.history.replaceState({}, '', url.toString()); - } }, []); const handlePauseWorkout = useCallback(() => { @@ -543,24 +517,13 @@ function App() { - {/* Show guest banner only when in guest mode and not authenticated */} - {isGuestMode && !isAuthenticated && ( -
- Guest Mode - Session data will not be saved. - -
- )} - {/* Sign-out from the auth dropdown also exits guest mode if active */} + {/* Session data will not be saved when unauthenticated */}
- {/* Quick Start CTA — shown only when NOT in guest mode, on the routes view */} - {currentView === 'routes' && !isGuestMode && ( -
-
- No account needed -

Just want to row?

-

Start instantly on Willowbrook River — no sign-up, no data saved.

-
- -
- )} - {currentView === 'routes' && selectedRoute && (
- - {/* Route Info Overlay */}
@@ -673,9 +612,7 @@ function App() {
- {isRouteDescriptionExpanded && ( -

{selectedRoute.description}

- )} +

{selectedRoute.description}

@@ -715,7 +652,7 @@ function App() {
- {!isGuestMode && ( + {showAuthFeatures && (

Routes

diff --git a/src/__tests__/app.test.tsx b/src/__tests__/app.test.tsx index 57a0bba..5f80dcf 100644 --- a/src/__tests__/app.test.tsx +++ b/src/__tests__/app.test.tsx @@ -1,7 +1,9 @@ -import { afterAll, beforeAll, describe, it, expect, vi } from 'vitest'; +import { afterAll, afterEach, beforeAll, describe, it, expect, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; import App from '../App'; import { formatPace } from '../utils/formatters'; +import * as AuthContext from '../context/AuthContext'; +import type { AuthContextValue } from '../context/AuthContext'; const originalGetContext = HTMLCanvasElement.prototype.getContext; @@ -67,27 +69,61 @@ describe('App component', () => { expect(formatPace(359)).toBe('5:59/500m'); }); - it('shows Quick Start button in normal mode', () => { + it('does not show Quick Start button', () => { render(); - expect(screen.getByRole('button', { name: /Quick Start/i })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Quick Start/i })).not.toBeInTheDocument(); }); - it('shows route-only navigation in guest mode (?guest=true)', () => { - // Simulate URL param - const url = new URL(window.location.href); - url.searchParams.set('guest', 'true'); - window.history.replaceState({}, '', url.toString()); - + it('shows route-only navigation for unauthenticated users (no History tab)', () => { render(); expect(screen.getByRole('button', { name: /Routes/i })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: /History/i })).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: /Workouts/i })).not.toBeInTheDocument(); - expect(screen.getAllByText(/Guest Mode/i).length).toBeGreaterThan(0); + }); + + it('shows Rower Device and Heart Rate panels for unauthenticated users without guest sidebar class', () => { + const { container } = render(); + + expect(screen.getByText(/Rower Device/i)).toBeInTheDocument(); + expect(screen.getByText(/Heart Rate/i)).toBeInTheDocument(); + // GUEST-2: sidebar must not carry app-sidebar--guest (which previously hid device panels) + const sidebar = container.querySelector('.app-sidebar'); + expect(sidebar?.classList.contains('app-sidebar--guest')).toBe(false); + }); + + it('does not show Import Route button for unauthenticated users', () => { + render(); + expect(screen.queryByRole('button', { name: /Import Route/i })).not.toBeInTheDocument(); + }); + + it('does not show Open rownative.icu button for unauthenticated users', () => { + render(); + expect(screen.queryByRole('button', { name: /rownative\.icu/i })).not.toBeInTheDocument(); + }); + + describe('authenticated user', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('shows Import Route and Open rownative.icu buttons when logged in', () => { + const authedValue: AuthContextValue = { + user: { id: 'i12345', name: 'Test User', email: 'test@example.com' }, + isAuthenticated: true, + isLoading: false, + authError: null, + login: vi.fn(), + logout: vi.fn(), + clearAuthError: vi.fn(), + pendingAction: null, + setPendingAction: vi.fn(), + }; + vi.spyOn(AuthContext, 'useAuth').mockReturnValue(authedValue); + + render(); - // Clean up URL - const cleanUrl = new URL(window.location.href); - cleanUrl.searchParams.delete('guest'); - window.history.replaceState({}, '', cleanUrl.toString()); + expect(screen.getByRole('button', { name: /Import Route/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /rownative\.icu/i })).toBeInTheDocument(); + }); }); }); diff --git a/src/components/GuestSessionSummary.tsx b/src/components/GuestSessionSummary.tsx index 31f69a6..e1f41e4 100644 --- a/src/components/GuestSessionSummary.tsx +++ b/src/components/GuestSessionSummary.tsx @@ -77,7 +77,7 @@ export function GuestSessionSummary({ session, onRowAgain, onExit }: GuestSessio ▶ Row Again