|
1 | 1 | import { test, expect } from '@playwright/test' |
2 | 2 |
|
3 | 3 | const breakpoints = { |
4 | | - mobile: { width: 375, height: 667, name: 'Mobile' }, |
5 | | - xs: { width: 480, height: 800, name: 'XS' }, |
6 | | - sm: { width: 640, height: 800, name: 'SM' }, |
7 | | - md: { width: 768, height: 1024, name: 'MD' }, |
8 | | - lg: { width: 1024, height: 768, name: 'LG' }, |
9 | | - xl: { width: 1280, height: 1024, name: 'XL' }, |
10 | | - '2xl': { width: 1536, height: 1024, name: '2XL' }, |
| 4 | + mobile: 375, |
| 5 | + xs: 480, |
| 6 | + sm: 640, |
| 7 | + md: 768, |
| 8 | + lg: 1024, |
| 9 | + xl: 1280, |
| 10 | + '2xl': 1536, |
11 | 11 | } |
12 | 12 |
|
13 | | -test.describe('Responsive Layout Validation', () => { |
14 | | - test.beforeEach(async ({ page }) => { |
15 | | - await page.goto('/') |
16 | | - }) |
17 | | - |
18 | | - for (const [key, viewport] of Object.entries(breakpoints)) { |
19 | | - test(`should render correctly at ${viewport.name} (${viewport.width}x${viewport.height})`, async ({ |
| 13 | +test.describe('Journal App Responsive Validation', () => { |
| 14 | + const testBreakpoints = [ |
| 15 | + { name: 'Mobile', width: breakpoints.mobile, height: 667 }, |
| 16 | + { name: 'XS', width: breakpoints.xs, height: 800 }, |
| 17 | + { name: 'SM', width: breakpoints.sm, height: 800 }, |
| 18 | + { name: 'MD', width: breakpoints.md, height: 1024 }, |
| 19 | + { name: 'LG', width: breakpoints.lg, height: 768 }, |
| 20 | + { name: 'XL', width: breakpoints.xl, height: 1024 }, |
| 21 | + { name: '2XL', width: breakpoints['2xl'], height: 1024 }, |
| 22 | + ] |
| 23 | + |
| 24 | + testBreakpoints.forEach(({ name, width, height }) => { |
| 25 | + test(`should render correctly at ${name} (${width}x${height})`, async ({ |
20 | 26 | page, |
21 | 27 | }) => { |
22 | | - // Set viewport size |
23 | | - await page.setViewportSize({ |
24 | | - width: viewport.width, |
25 | | - height: viewport.height, |
26 | | - }) |
27 | | - |
28 | | - // Wait for layout to stabilize |
29 | | - await page.waitForLoadState('networkidle') |
30 | | - |
31 | | - // Check that main content is visible |
32 | | - await expect(page.locator('main')).toBeVisible() |
33 | | - await expect( |
34 | | - page.getByRole('heading', { name: 'Reflect App' }) |
35 | | - ).toBeVisible() |
36 | | - |
37 | | - // Verify no horizontal overflow |
38 | | - const bodyScrollWidth = await page.evaluate( |
39 | | - () => document.body.scrollWidth |
| 28 | + await page.goto('/') |
| 29 | + await page.setViewportSize({ width, height }) |
| 30 | + |
| 31 | + // Core app elements should be visible and functional |
| 32 | + await expect(page.locator('h1')).toBeVisible() |
| 33 | + await expect(page.getByTestId('journal-entry-input')).toBeVisible() |
| 34 | + await expect(page.getByTestId('reflect-now-button')).toBeVisible() |
| 35 | + |
| 36 | + // Test actual user interaction |
| 37 | + const input = page.getByTestId('journal-entry-input') |
| 38 | + await input.fill( |
| 39 | + 'This is a test entry to verify the app works at this screen size.' |
40 | 40 | ) |
41 | | - const windowInnerWidth = await page.evaluate(() => window.innerWidth) |
42 | | - expect(bodyScrollWidth).toBeLessThanOrEqual(windowInnerWidth + 1) // Allow 1px tolerance |
43 | 41 |
|
44 | | - // Check that current breakpoint indicator is showing correctly |
45 | | - if (key === 'mobile') { |
46 | | - const mobileIndicator = page |
47 | | - .locator('.bg-destructive') |
48 | | - .filter({ hasText: /Mobile/ }) |
49 | | - await expect(mobileIndicator).toBeVisible() |
50 | | - } else if (key === 'xs') { |
51 | | - const xsIndicator = page |
52 | | - .locator('.bg-chart-1') |
53 | | - .filter({ hasText: /XS \(480px\+\)/ }) |
54 | | - await expect(xsIndicator).toBeVisible() |
55 | | - } else if (key === 'sm') { |
56 | | - const smIndicator = page |
57 | | - .locator('.bg-chart-2') |
58 | | - .filter({ hasText: /SM \(640px\+\)/ }) |
59 | | - await expect(smIndicator).toBeVisible() |
60 | | - } else if (key === 'md') { |
61 | | - const mdIndicator = page |
62 | | - .locator('.bg-chart-3') |
63 | | - .filter({ hasText: /MD \(768px\+\)/ }) |
64 | | - await expect(mdIndicator).toBeVisible() |
65 | | - } else if (key === 'lg') { |
66 | | - const lgIndicator = page |
67 | | - .locator('.bg-chart-4') |
68 | | - .filter({ hasText: /LG \(1024px\+\)/ }) |
69 | | - await expect(lgIndicator).toBeVisible() |
70 | | - } else if (key === 'xl') { |
71 | | - const xlIndicator = page |
72 | | - .locator('.bg-chart-5') |
73 | | - .filter({ hasText: /XL \(1280px\+\)/ }) |
74 | | - await expect(xlIndicator).toBeVisible() |
75 | | - } else if (key === '2xl') { |
76 | | - const x2xlIndicator = page |
77 | | - .locator('.bg-primary') |
78 | | - .filter({ hasText: /2XL \(1536px\+\)/ }) |
79 | | - await expect(x2xlIndicator).toBeVisible() |
80 | | - } |
| 42 | + await expect(page.getByTestId('reflect-now-button')).toBeEnabled() |
81 | 43 |
|
82 | | - // Verify grid layout adapts correctly |
83 | | - const breakpointGrid = page.locator('[data-testid="breakpoint-grid"]') |
84 | | - await expect(breakpointGrid).toBeVisible() |
| 44 | + // Clear input should work |
| 45 | + await input.clear() |
| 46 | + await expect(page.getByTestId('reflect-now-button')).toBeDisabled() |
85 | 47 |
|
86 | | - // Take screenshot for visual validation |
87 | | - await page.screenshot({ |
88 | | - path: `e2e/screenshots/responsive-${key}-${viewport.width}x${viewport.height}.png`, |
89 | | - fullPage: true, |
| 48 | + // Theme toggle should be accessible |
| 49 | + const themeToggle = page.getByTestId('theme-toggle-advanced') |
| 50 | + await expect(themeToggle).toBeVisible() |
| 51 | + |
| 52 | + // No horizontal overflow |
| 53 | + const hasHorizontalOverflow = await page.evaluate(() => { |
| 54 | + return document.body.scrollWidth > document.body.clientWidth |
90 | 55 | }) |
| 56 | + expect(hasHorizontalOverflow).toBe(false) |
91 | 57 | }) |
92 | | - } |
93 | | - |
94 | | - test('should handle smooth transitions between breakpoints', async ({ |
95 | | - page, |
96 | | - }) => { |
97 | | - // Start at mobile size |
98 | | - await page.setViewportSize({ width: 375, height: 667 }) |
99 | | - await page.waitForLoadState('networkidle') |
100 | | - |
101 | | - // Gradually increase width to test transitions |
102 | | - const testWidths = [480, 640, 768, 1024, 1280, 1536] |
103 | | - |
104 | | - for (const width of testWidths) { |
105 | | - await page.setViewportSize({ width, height: 800 }) |
106 | | - |
107 | | - // Wait for layout to stabilize after viewport change |
108 | | - await page.waitForLoadState('networkidle') |
109 | | - |
110 | | - // Wait for the appropriate breakpoint indicator to be visible |
111 | | - // This ensures the CSS transitions have completed |
112 | | - if (width >= 1536) { |
113 | | - await expect( |
114 | | - page.locator('.bg-primary').filter({ hasText: /2XL \(1536px\+\)/ }) |
115 | | - ).toBeVisible() |
116 | | - } else if (width >= 1280) { |
117 | | - await expect( |
118 | | - page.locator('.bg-chart-5').filter({ hasText: /XL \(1280px\+\)/ }) |
119 | | - ).toBeVisible() |
120 | | - } else if (width >= 1024) { |
121 | | - await expect( |
122 | | - page.locator('.bg-chart-4').filter({ hasText: /LG \(1024px\+\)/ }) |
123 | | - ).toBeVisible() |
124 | | - } else if (width >= 768) { |
125 | | - await expect( |
126 | | - page.locator('.bg-chart-3').filter({ hasText: /MD \(768px\+\)/ }) |
127 | | - ).toBeVisible() |
128 | | - } else if (width >= 640) { |
129 | | - await expect( |
130 | | - page.locator('.bg-chart-2').filter({ hasText: /SM \(640px\+\)/ }) |
131 | | - ).toBeVisible() |
132 | | - } else if (width >= 480) { |
133 | | - await expect( |
134 | | - page.locator('.bg-chart-1').filter({ hasText: /XS \(480px\+\)/ }) |
135 | | - ).toBeVisible() |
136 | | - } |
137 | | - |
138 | | - // Verify no layout breaks |
139 | | - const bodyScrollWidth = await page.evaluate( |
140 | | - () => document.body.scrollWidth |
141 | | - ) |
142 | | - const windowInnerWidth = await page.evaluate(() => window.innerWidth) |
143 | | - expect(bodyScrollWidth).toBeLessThanOrEqual(windowInnerWidth + 1) |
144 | | - |
145 | | - // Verify content is still visible |
146 | | - await expect( |
147 | | - page.getByRole('heading', { name: 'Reflect App' }) |
148 | | - ).toBeVisible() |
149 | | - } |
150 | 58 | }) |
151 | 59 |
|
152 | 60 | test('should maintain proper spacing and alignment at all breakpoints', async ({ |
153 | 61 | page, |
154 | 62 | }) => { |
155 | | - for (const [, viewport] of Object.entries(breakpoints)) { |
156 | | - await page.setViewportSize({ |
157 | | - width: viewport.width, |
158 | | - height: viewport.height, |
159 | | - }) |
160 | | - await page.waitForLoadState('networkidle') |
| 63 | + await page.goto('/') |
161 | 64 |
|
162 | | - // Check header layout |
163 | | - const header = page.locator('header') |
164 | | - await expect(header).toBeVisible() |
| 65 | + for (const { width, height } of testBreakpoints) { |
| 66 | + await page.setViewportSize({ width, height }) |
165 | 67 |
|
166 | | - // Check that cards maintain proper spacing |
167 | | - const cards = page.locator('[data-testid="card"], .group') |
168 | | - const cardCount = await cards.count() |
| 68 | + // Main container should be centered |
| 69 | + const main = page.locator('main').first() |
| 70 | + await expect(main).toBeVisible() |
169 | 71 |
|
170 | | - if (cardCount > 0) { |
171 | | - for (let i = 0; i < cardCount; i++) { |
172 | | - await expect(cards.nth(i)).toBeVisible() |
173 | | - } |
174 | | - } |
| 72 | + // Elements should have proper spacing |
| 73 | + const input = page.getByTestId('journal-entry-input') |
| 74 | + const button = page.getByTestId('reflect-now-button') |
| 75 | + |
| 76 | + await expect(input).toBeVisible() |
| 77 | + await expect(button).toBeVisible() |
175 | 78 |
|
176 | | - // Verify form elements are properly sized |
177 | | - const buttons = page.locator('button') |
178 | | - const buttonCount = await buttons.count() |
| 79 | + // Button should be below input with proper spacing |
| 80 | + const inputBox = await input.boundingBox() |
| 81 | + const buttonBox = await button.boundingBox() |
179 | 82 |
|
180 | | - for (let i = 0; i < buttonCount; i++) { |
181 | | - const button = buttons.nth(i) |
182 | | - if (await button.isVisible()) { |
183 | | - const boundingBox = await button.boundingBox() |
184 | | - if (boundingBox) { |
185 | | - // Buttons should have reasonable touch target size on mobile (< 480px) |
186 | | - // Note: Some buttons might have borders/padding that affect rendered size |
187 | | - if (viewport.width < 480) { |
188 | | - expect(boundingBox.height).toBeGreaterThanOrEqual(36) |
189 | | - } else { |
190 | | - // At xs+ breakpoints, can be smaller |
191 | | - expect(boundingBox.height).toBeGreaterThanOrEqual(28) |
192 | | - } |
193 | | - } |
194 | | - } |
| 83 | + if (inputBox && buttonBox) { |
| 84 | + // Button should be below input (allowing for some margin) |
| 85 | + expect(buttonBox.y).toBeGreaterThan(inputBox.y + inputBox.height - 10) |
195 | 86 | } |
196 | 87 | } |
197 | 88 | }) |
198 | 89 |
|
199 | 90 | test('should handle container behavior correctly', async ({ page }) => { |
200 | | - for (const [, viewport] of Object.entries(breakpoints)) { |
201 | | - await page.setViewportSize({ |
202 | | - width: viewport.width, |
203 | | - height: viewport.height, |
204 | | - }) |
205 | | - await page.waitForLoadState('networkidle') |
206 | | - |
207 | | - // Check container max-width behavior |
208 | | - const container = page.locator('.container').first() |
209 | | - const containerBoundingBox = await container.boundingBox() |
210 | | - |
211 | | - if (containerBoundingBox) { |
212 | | - // Container should never exceed viewport width |
213 | | - expect(containerBoundingBox.width).toBeLessThanOrEqual(viewport.width) |
| 91 | + await page.goto('/') |
214 | 92 |
|
215 | | - // Container should be properly centered |
216 | | - const containerLeft = containerBoundingBox.x |
217 | | - const containerRight = containerLeft + containerBoundingBox.width |
218 | | - const viewportCenter = viewport.width / 2 |
219 | | - const containerCenter = (containerLeft + containerRight) / 2 |
| 93 | + for (const { width, height } of testBreakpoints.slice(0, 4)) { |
| 94 | + // Test smaller breakpoints |
| 95 | + await page.setViewportSize({ width, height }) |
220 | 96 |
|
221 | | - // Allow some tolerance for centering |
222 | | - expect(Math.abs(containerCenter - viewportCenter)).toBeLessThanOrEqual( |
223 | | - 20 |
224 | | - ) |
225 | | - } |
| 97 | + // Container should not exceed viewport width |
| 98 | + const bodyWidth = await page.evaluate(() => document.body.scrollWidth) |
| 99 | + expect(bodyWidth).toBeLessThanOrEqual(width + 1) // +1 for rounding |
226 | 100 | } |
227 | 101 | }) |
228 | 102 |
|
229 | 103 | test('should handle theme toggle interactions across breakpoints', async ({ |
230 | 104 | page, |
231 | 105 | }) => { |
232 | | - for (const [, viewport] of Object.entries(breakpoints)) { |
233 | | - await page.setViewportSize({ |
234 | | - width: viewport.width, |
235 | | - height: viewport.height, |
236 | | - }) |
237 | | - await page.waitForLoadState('networkidle') |
238 | | - |
239 | | - // Find theme toggle buttons |
240 | | - const themeToggle = page.locator('[data-testid="theme-toggle"]').first() |
241 | | - const themeToggleAdvanced = page |
242 | | - .locator('[data-testid="theme-toggle-advanced"]') |
243 | | - .first() |
244 | | - |
245 | | - // Test basic theme toggle |
246 | | - if (await themeToggle.isVisible()) { |
247 | | - await themeToggle.click() |
248 | | - |
249 | | - // Wait for theme change by checking that toggle button is still responsive |
250 | | - // and that the page has finished any theme transitions |
251 | | - await page.waitForLoadState('networkidle') |
252 | | - |
253 | | - // Simple verification that the theme system is working |
254 | | - await expect(themeToggle).toBeVisible() |
255 | | - |
256 | | - // Verify the toggle button has theme-aware icons (Sun/Moon) visible |
257 | | - const sunIcon = themeToggle.locator('svg').first() |
258 | | - await expect(sunIcon).toBeVisible() |
259 | | - } |
| 106 | + await page.goto('/') |
260 | 107 |
|
261 | | - // Test advanced theme toggle |
262 | | - if (await themeToggleAdvanced.isVisible()) { |
263 | | - await themeToggleAdvanced.click() |
| 108 | + for (const { width, height } of [ |
| 109 | + testBreakpoints[0], // Mobile |
| 110 | + testBreakpoints[3], // MD |
| 111 | + testBreakpoints[5], // XL |
| 112 | + ]) { |
| 113 | + await page.setViewportSize({ width, height }) |
264 | 114 |
|
265 | | - // Wait for any network activity to settle after theme change |
266 | | - await page.waitForLoadState('networkidle') |
| 115 | + const themeToggle = page.getByTestId('theme-toggle-advanced') |
| 116 | + await expect(themeToggle).toBeVisible() |
267 | 117 |
|
268 | | - // Verify button is still visible and functional |
269 | | - await expect(themeToggleAdvanced).toBeVisible() |
| 118 | + // Should be clickable at all screen sizes |
| 119 | + await themeToggle.click() |
| 120 | + await expect(themeToggle).toBeVisible() |
270 | 121 |
|
271 | | - // Verify button contains valid theme text (Light, Dark, or System) |
272 | | - const buttonText = await themeToggleAdvanced.textContent() |
273 | | - expect(buttonText).toMatch(/\b(Light|Dark|System)\b/) |
274 | | - } |
| 122 | + // App should remain functional after theme change |
| 123 | + await expect(page.getByTestId('journal-entry-input')).toBeVisible() |
| 124 | + await expect(page.getByTestId('reflect-now-button')).toBeVisible() |
275 | 125 | } |
276 | 126 | }) |
277 | 127 | }) |
0 commit comments