Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion app/client/connect/ConnectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,11 @@ export default function ConnectView({
!deviceStatus.includes('Disconnected') && (
<Alert
data-testid="connection-status-alert"
severity={deviceStatus.includes('Failed') ? 'error' : 'info'}
severity={
deviceStatus.toLowerCase().startsWith('failed')
? 'error'
: 'info'
}
sx={{ mb: 2 }}
>
{deviceStatus}
Expand Down
1 change: 1 addition & 0 deletions app/client/experimental/components/HeartRateTimeSeries.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const HeartRateTimeSeries = ({ hrHistory }: HeartRateTimeSeriesProps) => {
<Box
sx={{ height: 300, minHeight: 300 }}
data-testid="hr-time-series-chart"
data-vrt-mask="true"
>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={hrHistory} syncId="anyId">
Expand Down
11 changes: 10 additions & 1 deletion playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
},

Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
},
},
Expand Down
8 changes: 2 additions & 6 deletions tests/playwright/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
* // Wait utilities
* waitForPageReady,
* waitForWebSocketConnection,
* waitForFontsLoaded,
* waitForVRTReady,
*
* // Assertions
* assertPageSnapshot,
Expand All @@ -38,11 +38,11 @@ export {
// Wait functions
waitForPageReady,
waitForWebSocketConnection,
waitForFontsLoaded,
waitForElementStable,
waitForNetworkIdle,
waitForApiResponse,
waitForAllConditions,
waitForVRTReady,
} from './waits'

// ============================================================================
Expand Down Expand Up @@ -70,9 +70,6 @@ export {
VRT_MASK_SELECTORS,
// Mask helpers
getDynamicContentMasks,
getHrMasks,
getTimerMasks,
getSpotifyMasks,
} from './masks'

// ============================================================================
Expand Down Expand Up @@ -152,7 +149,6 @@ export {
takeScreenshot,
takeDashboardScreenshot,
assertFixedDimensions,
waitForVRTReady,
} from './visual'

// ============================================================================
Expand Down
60 changes: 11 additions & 49 deletions tests/playwright/lib/masks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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))
}
13 changes: 7 additions & 6 deletions tests/playwright/lib/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`)
}

/**
Expand Down Expand Up @@ -505,5 +506,5 @@ export async function startMockHrStreaming(mockPage: Page): Promise<void> {
export async function prepareForVisualRegression(
...pages: Page[]
): Promise<void> {
await Promise.all(pages.map((page) => waitForFontsLoaded(page)))
await Promise.all(pages.map((page) => waitForVRTReady(page)))
}
27 changes: 2 additions & 25 deletions tests/playwright/lib/visual.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<void> {
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.
*
Expand Down Expand Up @@ -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,
})
Expand Down
23 changes: 20 additions & 3 deletions tests/playwright/lib/waits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
export async function waitForVRTReady(
page: Page,
targetWidth?: number
): Promise<void> {
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
)
}
}

/**
Expand Down
21 changes: 7 additions & 14 deletions tests/playwright/vrt-components.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
mockSpotifyPlaybackState,
mockLoggedInSession,
resetServerState,
getSpotifyMasks,
getDynamicContentMasks,
} from './lib'
import { checkAccessibility } from './lib/accessibility'
import { takeScreenshot } from './lib/visual'
Expand All @@ -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 }) => {
Expand Down Expand Up @@ -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'],
},
Expand All @@ -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
Expand Down Expand Up @@ -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),
})
})

Expand All @@ -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,
})
Expand Down
Loading
Loading