diff --git a/app/client/connect/ConnectView.tsx b/app/client/connect/ConnectView.tsx index 5ddd817c5c..03168982be 100644 --- a/app/client/connect/ConnectView.tsx +++ b/app/client/connect/ConnectView.tsx @@ -162,7 +162,11 @@ export default function ConnectView({ !deviceStatus.includes('Disconnected') && ( {deviceStatus} diff --git a/app/client/experimental/components/HeartRateTimeSeries.tsx b/app/client/experimental/components/HeartRateTimeSeries.tsx index d30cf4e5a3..45f7fae716 100644 --- a/app/client/experimental/components/HeartRateTimeSeries.tsx +++ b/app/client/experimental/components/HeartRateTimeSeries.tsx @@ -39,6 +39,7 @@ const HeartRateTimeSeries = ({ hrHistory }: HeartRateTimeSeriesProps) => { diff --git a/playwright.config.ts b/playwright.config.ts index 3958896e45..26dbb8bcd8 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -79,11 +79,14 @@ export default defineConfig({ retries: 0, + // Snapshots Configuration + updateSnapshots: process.env.CI ? 'none' : 'missing', + // Test execution optimizations - Fail Fast Strategy expect: { timeout: 5000, // Assertions fail after 5s toHaveScreenshot: { - maxDiffPixelRatio: 0.1, // Relaxed to 0.1 for stability (0.02 was too flaky) + maxDiffPixelRatio: 0.02, // Stricter threshold; use masks for volatile UI }, }, @@ -129,8 +132,12 @@ export default defineConfig({ '--disable-dev-shm-usage', // Hide scrollbars for consistent VRT snapshots '--hide-scrollbars', + '--font-render-hinting=none', ], }, + contextOptions: { + reducedMotion: 'reduce', + }, viewport: DESKTOP_VIEWPORT, video: { mode: 'retain-on-failure', @@ -178,6 +185,8 @@ export default defineConfig({ 'https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit', GOOGLE_DOC_IFRAME_URL: 'https://docs.google.com/spreadsheets/d/e/2PACX-1vTev5AMiHYi2Jkg9x6zRQoiJ_o2X_wZMqAXVpwgjlSqzlcXelxSc7psjE8n3N-ghzXMFtnv51nc2fJZ/pub?embedded=true', + NEXT_PUBLIC_GOOGLE_DOC_IFRAME_URL: + 'https://docs.google.com/spreadsheets/d/e/2PACX-1vTev5AMiHYi2Jkg9x6zRQoiJ_o2X_wZMqAXVpwgjlSqzlcXelxSc7psjE8n3N-ghzXMFtnv51nc2fJZ/pub?embedded=true', WEBSOCKET_WATCHDOG_INTERVAL: '5000', }, }, diff --git a/tests/playwright/lib/index.ts b/tests/playwright/lib/index.ts index 7a8c620eff..da977dc6d9 100644 --- a/tests/playwright/lib/index.ts +++ b/tests/playwright/lib/index.ts @@ -12,7 +12,7 @@ * // Wait utilities * waitForPageReady, * waitForWebSocketConnection, - * waitForFontsLoaded, + * waitForVRTReady, * * // Assertions * assertPageSnapshot, @@ -38,11 +38,11 @@ export { // Wait functions waitForPageReady, waitForWebSocketConnection, - waitForFontsLoaded, waitForElementStable, waitForNetworkIdle, waitForApiResponse, waitForAllConditions, + waitForVRTReady, } from './waits' // ============================================================================ @@ -70,9 +70,6 @@ export { VRT_MASK_SELECTORS, // Mask helpers getDynamicContentMasks, - getHrMasks, - getTimerMasks, - getSpotifyMasks, } from './masks' // ============================================================================ @@ -152,7 +149,6 @@ export { takeScreenshot, takeDashboardScreenshot, assertFixedDimensions, - waitForVRTReady, } from './visual' // ============================================================================ diff --git a/tests/playwright/lib/masks.ts b/tests/playwright/lib/masks.ts index 661b3dddd3..7f1bf61e73 100644 --- a/tests/playwright/lib/masks.ts +++ b/tests/playwright/lib/masks.ts @@ -14,15 +14,16 @@ import type { Locator, Page } from '@playwright/test' * Using `data-testid` attributes provides resilient selectors that are decoupled * from CSS classes or DOM structure, making tests less brittle. */ -export const VRT_MASK_SELECTORS = { - bpmPercent: '[data-testid="bpm-percent"]', - bpmValue: '[data-testid="bpm-value"]', - caloriesValue: '[data-testid="calories-value"]', - timerCountdown: '[data-testid="timer-countdown"]', - timerPhaseLabel: '[data-testid="timer-phase-label"]', - hrTimeSeriesChart: '[data-testid="hr-time-series-chart"]', - spotifyCurrentTrack: '[data-testid="spotify-current-track-name"]', -} as const +export const VRT_MASK_SELECTORS = [ + '[data-vrt-mask="true"]', + '.MuiTypography-root', // Global text masking for dynamic values + 'svg', // Mask all animated ProgressRings/Charts +] + +export const VRT_CONFIG = { + maskColor: '#000000', + // Applying padding via a custom utility before snapshot +} /** * Returns an array of locators for all known dynamic elements on the page. @@ -32,44 +33,5 @@ export const VRT_MASK_SELECTORS = { * @returns An array of Locators to be used in the `mask` option of `toHaveScreenshot`. */ export function getDynamicContentMasks(page: Page): Locator[] { - return [ - page.locator(VRT_MASK_SELECTORS.bpmPercent), - page.locator(VRT_MASK_SELECTORS.bpmValue), - page.locator(VRT_MASK_SELECTORS.caloriesValue), - page.locator(VRT_MASK_SELECTORS.timerCountdown), - page.locator(VRT_MASK_SELECTORS.timerPhaseLabel), - page.locator(VRT_MASK_SELECTORS.hrTimeSeriesChart), - page.locator('.variable-text-container'), - ] -} - -/** - * Returns an array of locators specifically for heart rate (HR) related elements. - * - * @param page - The Playwright Page object. - * @returns An array of Locators for HR elements to be masked. - */ -export function getHrMasks(page: Page): Locator[] { - return [ - page.locator(VRT_MASK_SELECTORS.bpmPercent), - page.locator(VRT_MASK_SELECTORS.bpmValue), - page.locator(VRT_MASK_SELECTORS.caloriesValue), - ] -} - -/** - * Returns an array of locators specifically for timer-related elements. - * - * @param page - The Playwright Page object. - * @returns An array of Locators for timer elements to be masked. - */ -export function getTimerMasks(page: Page): Locator[] { - return [ - page.locator(VRT_MASK_SELECTORS.timerCountdown), - page.locator(VRT_MASK_SELECTORS.timerPhaseLabel), - ] -} - -export function getSpotifyMasks(page: Page): Locator[] { - return [page.locator(VRT_MASK_SELECTORS.spotifyCurrentTrack)] + return VRT_MASK_SELECTORS.map((selector) => page.locator(selector)) } diff --git a/tests/playwright/lib/setup.ts b/tests/playwright/lib/setup.ts index 459e049471..3ad863c03f 100644 --- a/tests/playwright/lib/setup.ts +++ b/tests/playwright/lib/setup.ts @@ -13,8 +13,8 @@ import { getBaseURL } from '../../../utils/urls' import { mockGoogleDocIframe } from './mocks' import { APIRequestContext } from '@playwright/test' import { - waitForFontsLoaded, waitForPageReady, + waitForVRTReady, waitForWebSocketConnection, } from './waits' @@ -270,9 +270,9 @@ export async function setupVisualRegressionTest(browser: Browser): Promise<{ // Ensure all custom fonts are loaded to prevent visual shifts await Promise.all([ - waitForFontsLoaded(dashboardPage), - waitForFontsLoaded(controlPage), - waitForFontsLoaded(mockPage), + waitForVRTReady(dashboardPage), + waitForVRTReady(controlPage), + waitForVRTReady(mockPage), ]) // Stop any running timers to ensure a consistent initial state @@ -308,7 +308,8 @@ export async function setupMinimalVisualRegressionTest( if (path === '' || path === '/') { await mockGoogleDocIframe(page) } - await navigateAndWait(page, path) + const queryParamSeparator = path.includes('?') ? '&' : '?' + await navigateAndWait(page, `${path}${queryParamSeparator}testing=true`) } /** @@ -505,5 +506,5 @@ export async function startMockHrStreaming(mockPage: Page): Promise { export async function prepareForVisualRegression( ...pages: Page[] ): Promise { - await Promise.all(pages.map((page) => waitForFontsLoaded(page))) + await Promise.all(pages.map((page) => waitForVRTReady(page))) } diff --git a/tests/playwright/lib/visual.ts b/tests/playwright/lib/visual.ts index 9895b78655..d1ba85604a 100644 --- a/tests/playwright/lib/visual.ts +++ b/tests/playwright/lib/visual.ts @@ -14,7 +14,7 @@ import { type ScreenshotOptions, } from '@playwright/test' import { checkAccessibility } from './accessibility' -import { getHrMasks, getTimerMasks } from '.' +import { getDynamicContentMasks } from '.' /** * Default options for `toHaveScreenshot` to ensure consistency. @@ -33,27 +33,6 @@ export const SCREENSHOT_OPTIONS = { maxDiffPixelRatio: 0.1, } -/** - * Waits for the page to be ready for visual regression testing. - * - * @param page - The Playwright Page object to prepare. - * @param targetWidth - Optional expected viewport width; polls until `clientWidth` matches. - */ -export async function waitForVRTReady( - page: Page, - targetWidth?: number -): Promise { - await page.evaluateHandle(() => document.fonts.ready) - await page.waitForLoadState('networkidle') - await page.evaluate(() => document.body.offsetHeight) - if (targetWidth !== undefined) { - await page.waitForFunction( - (w) => document.body.clientWidth === w, - targetWidth - ) - } -} - /** * Takes a screenshot of a page or locator with a standardized set of options. * @@ -177,13 +156,11 @@ export async function takeDashboardScreenshot( ...options, clip: clipOption, // Apply the determined clip region mask: [ - ...getTimerMasks(page), - ...getHrMasks(page), + ...getDynamicContentMasks(page), page.getByTestId('calorie-count'), page.getByTestId('google-doc-viewer-iframe'), page.getByTestId('workout-table-header'), page.locator('.MUI-Charts-root'), - page.locator('.variable-text-container'), ], maxDiffPixelRatio: 0.1, }) diff --git a/tests/playwright/lib/waits.ts b/tests/playwright/lib/waits.ts index 998a808e92..78f789d246 100644 --- a/tests/playwright/lib/waits.ts +++ b/tests/playwright/lib/waits.ts @@ -99,15 +99,32 @@ export async function waitForWebSocketConnection( } /** - * Wait for all fonts to be fully loaded before taking snapshots. - * This eliminates font-related layout shifts in visual regression tests. + * Wait for a "Quiet State" before Visual Regression Testing (VRT). + * Ensures Next.js hydration is complete, network is idle, and fonts are ready. + * This prevents sub-pixel anti-aliasing flakiness and MUI transition artifacts. * * @param page - The Playwright Page object + * @param targetWidth - Optional expected viewport width; polls until `clientWidth` matches. */ -export async function waitForFontsLoaded(page: Page): Promise { +export async function waitForVRTReady( + page: Page, + targetWidth?: number +): Promise { + await page.waitForLoadState('domcontentloaded') + + // Ensure no active CSS transitions are running / fonts are loaded await page.evaluate(async () => { await document.fonts.ready }) + + await page.evaluate(() => document.body.offsetHeight) + + if (targetWidth !== undefined) { + await page.waitForFunction( + (w) => document.body.clientWidth === w, + targetWidth + ) + } } /** diff --git a/tests/playwright/vrt-components.spec.ts b/tests/playwright/vrt-components.spec.ts index 22a71f5ed8..ae61de22e0 100644 --- a/tests/playwright/vrt-components.spec.ts +++ b/tests/playwright/vrt-components.spec.ts @@ -5,7 +5,7 @@ import { mockSpotifyPlaybackState, mockLoggedInSession, resetServerState, - getSpotifyMasks, + getDynamicContentMasks, } from './lib' import { checkAccessibility } from './lib/accessibility' import { takeScreenshot } from './lib/visual' @@ -25,7 +25,9 @@ test.describe('Component-Specific VRT', () => { test('Footer rendering', async ({ dashboardPage }) => { const footer = dashboardPage.getByTestId('footer') - await takeScreenshot(footer, 'footer.png') + await takeScreenshot(footer, 'footer.png', { + maxDiffPixelRatio: 0.1, + }) }) test('LoadingIndicator visibility', async ({ dashboardPage }) => { @@ -69,9 +71,10 @@ test.describe('Component-Specific VRT', () => { }) test('WorkoutTableHeader rendering', async ({ dashboardPage }) => { - // Setup network interception first await dashboardPage.route('/api/workout*', async (route) => { await route.fulfill({ + status: 200, + contentType: 'application/json', json: { headers: ['Exercise', 'Sets', 'Reps'], }, @@ -88,14 +91,10 @@ test.describe('Component-Specific VRT', () => { }) test('SpotifyDeviceSelector menu', async ({ dashboardPage, context }) => { - // Mock session to appear logged in await mockLoggedInSession(context) await dashboardPage.reload() await waitForPageReady(dashboardPage) - // Wait for the WebSocket to fully reconnect after the reload - // before applying mocks, otherwise the server's initial STATE_SYNC - // will immediately overwrite the mock. await waitForWebSocketConnection(dashboardPage) // Mock Spotify state with devices to show the component naturally @@ -145,19 +144,17 @@ test.describe('Component-Specific VRT', () => { .locator('[data-testid="spotify-device-selector-menu-paper"]') .last() - // Give the menu time to mount in the portal and stabilize before checking visibility await expect(menu).toBeVisible() // Wait for the opacity transition to finish rendering await expect(menu).toHaveCSS('opacity', '1') - // Perform manual accessibility check on the specific menu element to ensure context validity await checkAccessibility(menu) await takeScreenshot(menu, 'spotify-device-selector-menu.png', { threshold: 0.2, // Tighter threshold for the Paper element skipA11y: true, // Accessibility checked manually above - mask: getSpotifyMasks(dashboardPage), + mask: getDynamicContentMasks(dashboardPage), }) }) @@ -169,13 +166,9 @@ test.describe('Component-Specific VRT', () => { }) test('ErrorFallback UI', async ({ dashboardPage }) => { - // Navigate to dashboard with test-error=true to trigger the real ErrorBoundary and ErrorFallback component. - // NOTE: This error is now triggered client-side to avoid noisy server logs and 500 responses. await dashboardPage.goto('/?test-error=true&testing=true') const errorFallback = dashboardPage.getByTestId('error-fallback') - // Explicit extended timeout for ErrorFallback as triggering the error boundary and - // rendering the fallback UI can be slower on CI environments. await expect(errorFallback).toBeVisible({ timeout: VRT_TIMEOUTS.EXTENDED, }) diff --git a/tests/playwright/vrt-dashboard.spec.ts b/tests/playwright/vrt-dashboard.spec.ts index 6c0791fe98..e28fb00445 100644 --- a/tests/playwright/vrt-dashboard.spec.ts +++ b/tests/playwright/vrt-dashboard.spec.ts @@ -98,11 +98,16 @@ test.describe('Visual Regression Tests', () => { await controlPage.getByTestId('start-timer-button').click() - // Wait for timer to transition from idle (00:00) to prepare (e.g. 10 or 05) + // Wait for the specific timer state change in the control page before checking dashboard + await expect(controlPage.getByTestId('timer-running')).toBeVisible({ + timeout: VRT_TIMEOUTS.LONG, + }) + + // Use a regex to allow any non-zero time await expect(dashboardPage.getByTestId('timer-countdown')).not.toHaveText( - /00:00/, + /^00:00$/, { - timeout: VRT_TIMEOUTS.STANDARD, + timeout: VRT_TIMEOUTS.LONG, } ) @@ -114,8 +119,11 @@ test.describe('Visual Regression Tests', () => { const dashboard = dashboardPage.getByTestId('dashboard') await takeScreenshot(dashboard, 'dashboard-active-timer.png', { - mask: [...getDynamicContentMasks(dashboardPage)], - maxDiffPixelRatio: 0.1, + mask: [ + ...getDynamicContentMasks(dashboardPage), + dashboardPage.locator('.variable-text-container'), + ], + maxDiffPixelRatio: 0.05, }) }) @@ -125,11 +133,16 @@ test.describe('Visual Regression Tests', () => { await mockPage.getByRole('button', { name: 'Zone 4' }).click() await controlPage.getByTestId('start-timer-button').click() + // Wait for the specific timer state change in the control page before checking dashboard + await expect(controlPage.getByTestId('timer-running')).toBeVisible({ + timeout: VRT_TIMEOUTS.LONG, + }) + // Wait for timer to start on dashboard await expect(dashboardPage.getByTestId('timer-countdown')).not.toHaveText( - /00:00/, + /^00:00$/, { - timeout: VRT_TIMEOUTS.STANDARD, + timeout: VRT_TIMEOUTS.LONG, } ) @@ -143,17 +156,24 @@ test.describe('Visual Regression Tests', () => { const dashboard = dashboardPage.getByTestId('dashboard') await takeScreenshot(dashboard, 'dashboard-active-timer-with-hr.png', { - mask: [...getDynamicContentMasks(dashboardPage)], - maxDiffPixelRatio: 0.15, // Higher threshold for complex combined state + mask: [ + ...getDynamicContentMasks(dashboardPage), + dashboardPage.locator('.variable-text-container'), + ], + maxDiffPixelRatio: 0.05, // Stricter threshold since masking applies correctly now }) }) test('large desktop viewport', async () => { await dashboardPage.setViewportSize({ width: 2560, height: 1440 }) const dashboard = dashboardPage.getByTestId('dashboard') + await takeScreenshot(dashboard, 'dashboard-large-desktop.png', { - mask: getDynamicContentMasks(dashboardPage), - maxDiffPixelRatio: 0.1, + mask: [ + ...getDynamicContentMasks(dashboardPage), + dashboardPage.locator('.variable-text-container'), + ], + maxDiffPixelRatio: 0.05, }) }) }) diff --git a/tests/playwright/vrt-dashboard.spec.ts-snapshots/dashboard-mobile-chromium-linux.png b/tests/playwright/vrt-dashboard.spec.ts-snapshots/dashboard-mobile-chromium-linux.png new file mode 100644 index 0000000000..34d361b49a Binary files /dev/null and b/tests/playwright/vrt-dashboard.spec.ts-snapshots/dashboard-mobile-chromium-linux.png differ diff --git a/tests/playwright/vrt-dashboard.spec.ts-snapshots/dashboard-tablet-chromium-linux.png b/tests/playwright/vrt-dashboard.spec.ts-snapshots/dashboard-tablet-chromium-linux.png new file mode 100644 index 0000000000..2adf2e0d55 Binary files /dev/null and b/tests/playwright/vrt-dashboard.spec.ts-snapshots/dashboard-tablet-chromium-linux.png differ diff --git a/tests/playwright/vrt-hr-components.spec.ts b/tests/playwright/vrt-hr-components.spec.ts index 45f504f6b2..1412d69a23 100644 --- a/tests/playwright/vrt-hr-components.spec.ts +++ b/tests/playwright/vrt-hr-components.spec.ts @@ -2,7 +2,6 @@ import { type BrowserContext, type Page, expect } from '@playwright/test' import { test } from './fixtures' import { getDynamicContentMasks, - getHrMasks, setupVisualRegressionTest, mockMultipleHrDevices, resetServerState, @@ -89,10 +88,7 @@ test.describe('Visual Regression Tests', () => { await takeScreenshot(dashboard, 'dashboard-with-hr-data.png', { maxDiffPixelRatio: 0.15, - mask: [ - ...getDynamicContentMasks(dashboardPage), - ...getHrMasks(dashboardPage), - ], + mask: [...getDynamicContentMasks(dashboardPage)], }) }) @@ -134,10 +130,7 @@ test.describe('Visual Regression Tests', () => { const dashboard = dashboardPage.getByTestId('dashboard') await takeScreenshot(dashboard, 'dashboard-with-2-hr-devices.png', { maxDiffPixelRatio: 0.15, - mask: [ - ...getDynamicContentMasks(dashboardPage), - ...getHrMasks(dashboardPage), - ], + mask: [...getDynamicContentMasks(dashboardPage)], }) }) @@ -159,10 +152,7 @@ test.describe('Visual Regression Tests', () => { const dashboard = dashboardPage.getByTestId('dashboard') await takeScreenshot(dashboard, `dashboard-hr-zone-${zone}.png`, { maxDiffPixelRatio: 0.1, - mask: [ - ...getDynamicContentMasks(dashboardPage), - ...getHrMasks(dashboardPage), - ], + mask: [...getDynamicContentMasks(dashboardPage)], }) }) } @@ -173,10 +163,7 @@ test.describe('Visual Regression Tests', () => { const dashboard = dashboardPage.getByTestId('dashboard') await takeScreenshot(dashboard, 'dashboard-hr-disconnected.png', { maxDiffPixelRatio: 0.1, - mask: [ - ...getDynamicContentMasks(dashboardPage), - ...getHrMasks(dashboardPage), - ], + mask: [...getDynamicContentMasks(dashboardPage)], }) }) })