Skip to content

Commit a4f5c14

Browse files
committed
Fix flaky E2E tests - improve waitHelpers and readiness logic
1 parent 81d7b38 commit a4f5c14

6 files changed

Lines changed: 95 additions & 48 deletions

File tree

‎test/e2e/helpers/pageObjectModels/BasePage.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
} from '@playwright/test'
1414
import { navigationItems } from '@components/Navigation/server'
1515
import { clearConsentCookies } from '@test/e2e/helpers'
16+
import { waitForHeaderComponents as waitForHeaderComponentsHelper } from '@test/e2e/helpers/waitHelpers'
1617

1718
const DEFAULT_NAVIGATION_TIMEOUT = 5000
1819
const EXTENDED_NAVIGATION_TIMEOUT = 15000
@@ -451,6 +452,14 @@ export class BasePage {
451452
this.lastAstroPageLoadCount = await this._page.evaluate(() => window.__astroPageLoadCounter ?? 0)
452453
}
453454

455+
/**
456+
* Wait for navigation header components (theme picker + nav) to hydrate
457+
* Ensures client-side navigation helpers can safely interact with header UI
458+
*/
459+
async waitForHeaderComponents(options?: { timeout?: number }): Promise<void> {
460+
await waitForHeaderComponentsHelper(this._page, options?.timeout)
461+
}
462+
454463
/**
455464
* Navigate to a page using Astro View Transitions
456465
* Clicks a link with the given href to trigger client-side navigation
@@ -464,6 +473,7 @@ export class BasePage {
464473
* ```
465474
*/
466475
async navigateToPage(href: string): Promise<void> {
476+
await this.waitForHeaderComponents()
467477
const navLink = this._page.locator(`site-navigation a[href="${href}"]`).first()
468478
const linkCount = await navLink.count()
469479

‎test/e2e/helpers/waitHelpers.ts‎

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,58 @@
44
*/
55
import type { Page } from '@playwright/test'
66

7+
const THEME_PICKER_READY_ATTR = 'data-theme-picker-ready'
8+
const NAVIGATION_READY_ATTR = 'data-nav-ready'
9+
const DEFAULT_COMPONENT_READY_TIMEOUT = 5000
10+
11+
const isContextDestroyedError = (error: unknown) => {
12+
if (!(error instanceof Error)) return false
13+
const message = error.message ?? ''
14+
return message.includes('Execution context was destroyed') || message.includes('Target closed')
15+
}
16+
717
/**
818
* Wait for the browser to render a specific number of animation frames
919
* Uses requestAnimationFrame so it synchronizes with real layout/paint updates
1020
*/
1121
export async function waitForAnimationFrames(page: Page, frameCount: number = 2): Promise<void> {
12-
await page.evaluate(async (count) => {
13-
for (let index = 0; index < count; index++) {
14-
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()))
22+
let remaining = frameCount
23+
24+
while (remaining > 0) {
25+
try {
26+
await page.evaluate(async () => {
27+
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()))
28+
})
29+
remaining -= 1
30+
} catch (error) {
31+
if (isContextDestroyedError(error)) {
32+
await page.waitForLoadState('domcontentloaded')
33+
continue
34+
}
35+
throw error
1536
}
16-
}, frameCount)
37+
}
38+
}
39+
40+
export async function waitForThemePickerReady(page: Page, timeout = DEFAULT_COMPONENT_READY_TIMEOUT): Promise<void> {
41+
await page.waitForFunction(
42+
attr => document.querySelector('theme-picker')?.getAttribute(attr) === 'true',
43+
THEME_PICKER_READY_ATTR,
44+
{ timeout }
45+
)
46+
}
47+
48+
export async function waitForNavigationReady(page: Page, timeout = DEFAULT_COMPONENT_READY_TIMEOUT): Promise<void> {
49+
await page.waitForFunction(
50+
attr => document.querySelector('site-navigation')?.getAttribute(attr) === 'true',
51+
NAVIGATION_READY_ATTR,
52+
{ timeout }
53+
)
54+
}
55+
56+
export async function waitForHeaderComponents(page: Page, timeout = DEFAULT_COMPONENT_READY_TIMEOUT): Promise<void> {
57+
await Promise.all([
58+
waitForThemePickerReady(page, timeout),
59+
waitForNavigationReady(page, timeout),
60+
])
1761
}

‎test/e2e/specs/04-components/theme-picker.spec.ts‎

Lines changed: 8 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -13,29 +13,6 @@ import {
1313
test,
1414
} from '@test/e2e/helpers'
1515

16-
type PlaywrightPage = import('@playwright/test').Page
17-
18-
const THEME_PICKER_READY_ATTR = 'data-theme-picker-ready'
19-
const NAVIGATION_READY_ATTR = 'data-nav-ready'
20-
21-
const waitForThemePickerReady = async (page: PlaywrightPage) => {
22-
await page.waitForFunction((attr) => {
23-
const host = document.querySelector('theme-picker')
24-
return host?.getAttribute(attr) === 'true'
25-
}, THEME_PICKER_READY_ATTR)
26-
}
27-
28-
const waitForNavigationReady = async (page: PlaywrightPage) => {
29-
await page.waitForFunction((attr) => {
30-
const nav = document.querySelector('site-navigation')
31-
return nav?.getAttribute(attr) === 'true'
32-
}, NAVIGATION_READY_ATTR)
33-
}
34-
35-
const waitForHeaderComponents = async (page: PlaywrightPage) => {
36-
await Promise.all([waitForThemePickerReady(page), waitForNavigationReady(page)])
37-
}
38-
3916
const getDefaultNavigationHref = (basePage: BasePage) => basePage.navigationItems[0]?.url ?? '/about'
4017

4118
/**
@@ -61,7 +38,7 @@ async function navigateWithMobileSupport(basePage: BasePage, href: string = getD
6138
await basePage.waitForPageLoad()
6239
// Header components rehydrate asynchronously after View Transitions; make sure
6340
// the theme picker + navigation are ready before interacting again.
64-
await waitForHeaderComponents(page)
41+
await basePage.waitForHeaderComponents()
6542

6643
// On mobile, the menu should automatically close after navigation
6744
// But let's ensure it's closed by checking and closing if needed
@@ -85,7 +62,6 @@ test.describe('Theme Picker Component', () => {
8562
* - Performs hard reload to bypass View Transitions cache
8663
* - Dismisses cookie consent modal so it doesn't interfere with theme picker interactions
8764
* - Ensures consistent starting state for theme testing (no persisted theme preferences)
88-
*
8965
* Without this setup, tests would fail due to:
9066
* - Leftover theme preferences from previous tests affecting assertions
9167
* - Cookie modal blocking theme picker UI interactions
@@ -94,7 +70,7 @@ test.describe('Theme Picker Component', () => {
9470
test.beforeEach(async ({ page: playwrightPage }) => {
9571
const page = await BasePage.init(playwrightPage)
9672
await setupCleanTestPage(page.page)
97-
await waitForHeaderComponents(page.page)
73+
await page.waitForHeaderComponents()
9874
})
9975

10076
test('@ready theme picker is visible', async ({ page: playwrightPage }) => {
@@ -194,14 +170,14 @@ test.describe('Theme Picker Component', () => {
194170
* - Dismisses cookie modal to prevent UI interference
195171
* - Ensures clean state needed to properly test theme persistence behavior
196172
*
197-
* Without this setup, persistence tests would be unreliable due to:
173+
await page.waitForHeaderComponents()
198174
* - Pre-existing theme preferences making it impossible to verify persistence from scratch
199175
* - Cached state from previous tests affecting reload behavior
200176
*/
201177
test.beforeEach(async ({ page: playwrightPage }) => {
202178
const page = await BasePage.init(playwrightPage)
203179
await setupCleanTestPage(page.page)
204-
await waitForHeaderComponents(page.page)
180+
await page.waitForHeaderComponents()
205181
})
206182

207183
test('@ready theme preference persists across page reloads', async ({ page: playwrightPage }) => {
@@ -228,7 +204,7 @@ test.describe('Theme Picker Component', () => {
228204

229205
// This test needs its own setup without localStorage clearing
230206
await setupTestPage(page.page)
231-
await waitForHeaderComponents(page.page)
207+
await page.waitForHeaderComponents()
232208

233209
// Select dark theme using helper
234210
await selectTheme(page.page, 'dark')
@@ -325,7 +301,7 @@ test.describe('Theme Picker Component', () => {
325301
await setupTestPage(page.page, '/')
326302
await page.evaluate(() => localStorage.clear())
327303
await page.reload()
328-
await waitForHeaderComponents(page.page)
304+
await page.waitForHeaderComponents()
329305
})
330306

331307
test('theme picker button works after View Transition navigation', async ({ page: playwrightPage }) => {
@@ -334,7 +310,7 @@ test.describe('Theme Picker Component', () => {
334310
// Root Cause: Scripts didn't properly reinitialize on View Transitions
335311
// Fix: Migrated to Web Component pattern with connectedCallback/disconnectedCallback lifecycle
336312

337-
// 1. Verify theme picker works on initial page load
313+
await page.waitForHeaderComponents()
338314
const themeToggleBtn = page.locator('.theme-toggle-btn').first()
339315
await expect(themeToggleBtn).toBeVisible()
340316

@@ -432,7 +408,7 @@ test.describe('Theme Picker Component', () => {
432408
await setupTestPage(page.page, '/')
433409
await page.evaluate(() => localStorage.clear())
434410
await page.reload()
435-
await waitForHeaderComponents(page.page)
411+
await page.waitForHeaderComponents()
436412
})
437413

438414
test('preserves lang attribute across navigation', async ({ page: playwrightPage }) => {

‎test/e2e/specs/11-regression/hero-animation-mobile-menu-pause.spec.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ test.describe('Hero Animation - Mobile Menu Pause Regression', () => {
3636
test.beforeEach(async ({ page: playwrightPage }) => {
3737
const page = await BasePage.init(playwrightPage)
3838
await setupTestPage(page.page, '/')
39+
await page.waitForHeaderComponents()
3940
// Wait for hero animation to load
4041
await page.waitForSelector('#heroAnimation', { timeout: 5000 })
4142
})
@@ -238,6 +239,7 @@ test.describe('Hero Animation - Mobile Menu Pause Regression', () => {
238239

239240
// Wait for navigation
240241
await page.waitForLoadState('networkidle')
242+
await page.waitForHeaderComponents()
241243

242244
// Menu should close after navigation
243245
const hamburgerAfterNav = page.locator('.nav-toggle-btn').first()

‎test/e2e/specs/12-component-persistence/body-visibility.spec.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,13 @@ test.describe('View Transitions - body visibility reset', () => {
1818

1919
await page.goto('/')
2020
await page.waitForLoadState('networkidle')
21+
await page.waitForHeaderComponents()
2122

2223
const astroBeforeSwapLog = page.consoleMssgPromise('Theme init on "astro:before-swap" executed')
2324

2425
await page.navigateToPage('/about')
2526
await page.waitForPageLoad()
27+
await page.waitForHeaderComponents()
2628

2729
const logMessage = await astroBeforeSwapLog
2830
expect(logMessage.text()).toContain('Theme init on "astro:before-swap" executed')

‎test/e2e/specs/12-component-persistence/head.spec.ts‎

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,26 @@
1313

1414
import { ComponentPersistencePage, test, describe, expect } from '@test/e2e/helpers'
1515

16+
const navigateAndAwaitHydration = async (
17+
page: ComponentPersistencePage,
18+
href: string,
19+
urlPattern: string | RegExp,
20+
timeout = 5000
21+
) => {
22+
await page.navigateToPage(href)
23+
await page.waitForPageLoad()
24+
await page.waitForHeaderComponents()
25+
await page.waitForURL(urlPattern, { timeout })
26+
}
27+
1628
describe('View Transitions - transition:persist on meta theme-color', () => {
1729
test('should persist meta theme-color element across navigation', async ({
1830
page: playwrightPage,
1931
}) => {
2032
const page = await ComponentPersistencePage.init(playwrightPage)
2133

2234
await page.goto('/')
35+
await page.waitForHeaderComponents()
2336

2437
// Set up test data on the meta theme-color element
2538
const initialData = await page.setupPersistenceTest('meta[name="theme-color"]')
@@ -33,8 +46,7 @@ describe('View Transitions - transition:persist on meta theme-color', () => {
3346
expect(initialContent).toMatch(/^#[0-9a-fA-F]{6}$/)
3447

3548
// Navigate to a different page using Astro's View Transitions
36-
await page.navigateToPage('/articles')
37-
await page.waitForURL('**/articles', { timeout: 5000 })
49+
await navigateAndAwaitHydration(page, '/articles', '**/articles')
3850

3951
// Verify the element persisted with the same DOM identity
4052
const afterNavigationData = await page.verifyPersistence('meta[name="theme-color"]')
@@ -56,6 +68,7 @@ describe('View Transitions - transition:persist on meta theme-color', () => {
5668
}) => {
5769
const page = await ComponentPersistencePage.init(playwrightPage)
5870
await page.goto('/')
71+
await page.waitForHeaderComponents()
5972

6073
// Mark the <head> element to verify it persists (Astro behavior)
6174
const headData = await page.evaluate(() => {
@@ -89,8 +102,7 @@ describe('View Transitions - transition:persist on meta theme-color', () => {
89102
expect(metaData.content).toMatch(/^#[0-9a-fA-F]{6}$/)
90103

91104
// Navigate using View Transitions
92-
await page.navigateToPage('/articles')
93-
await page.waitForURL('**/articles', { timeout: 5000 })
105+
await navigateAndAwaitHydration(page, '/articles', '**/articles')
94106

95107
// Check if head element persisted (Astro keeps the same head element)
96108
const afterNavigationHead = await page.evaluate(() => {
@@ -128,6 +140,7 @@ describe('View Transitions - transition:persist on meta theme-color', () => {
128140
}) => {
129141
const page = await ComponentPersistencePage.init(playwrightPage)
130142
await page.goto('/')
143+
await page.waitForHeaderComponents()
131144

132145
// Get initial theme-color value
133146
const initialColor = await page.evaluate(() => {
@@ -138,8 +151,7 @@ describe('View Transitions - transition:persist on meta theme-color', () => {
138151
expect(initialColor).toMatch(/^#[0-9a-fA-F]{6}$/)
139152

140153
// Navigate to another page
141-
await page.navigateToPage('/services')
142-
await page.waitForURL('**/services', { timeout: 5000 })
154+
await navigateAndAwaitHydration(page, '/services', '**/services')
143155

144156
// Get theme-color value after navigation
145157
const afterNavigationColor = await page.evaluate(() => {
@@ -154,6 +166,7 @@ describe('View Transitions - transition:persist on meta theme-color', () => {
154166
// Home is not listed in the navigation menu, so reload the page to return
155167
await page.goto('/')
156168
await page.waitForURL(/\/$/, { timeout: 5000 })
169+
await page.waitForHeaderComponents()
157170

158171
// Verify theme-color is still the same
159172
const finalColor = await page.evaluate(() => {
@@ -170,6 +183,7 @@ describe('View Transitions - transition:persist on meta theme-color', () => {
170183
}) => {
171184
const page = await ComponentPersistencePage.init(playwrightPage)
172185
await page.goto('/')
186+
await page.waitForHeaderComponents()
173187

174188
// Mark the canonical link element to check if it's replaced or just updated
175189
const initialData = await page.evaluate(() => {
@@ -190,8 +204,7 @@ describe('View Transitions - transition:persist on meta theme-color', () => {
190204
expect(initialData.href).toMatch(/\/$/)
191205

192206
// Navigate to articles page
193-
await page.navigateToPage('/articles')
194-
await page.waitForURL('**/articles', { timeout: 5000 })
207+
await navigateAndAwaitHydration(page, '/articles', '**/articles')
195208

196209
// Check if element was replaced or just updated
197210
const afterNavigationData = await page.evaluate(() => {
@@ -225,6 +238,7 @@ describe('View Transitions - transition:persist on meta theme-color', () => {
225238
}) => {
226239
const page = await ComponentPersistencePage.init(playwrightPage)
227240
await page.goto('/')
241+
await page.waitForHeaderComponents()
228242

229243
// Get initial sitemap URL
230244
const initialSitemap = await page.evaluate(() => {
@@ -235,8 +249,7 @@ describe('View Transitions - transition:persist on meta theme-color', () => {
235249
expect(initialSitemap).toBe('/sitemap-index.xml')
236250

237251
// Navigate to services page
238-
await page.navigateToPage('/services')
239-
await page.waitForURL('**/services', { timeout: 5000 })
252+
await navigateAndAwaitHydration(page, '/services', '**/services')
240253

241254
// Get sitemap URL after navigation
242255
const afterNavigationSitemap = await page.evaluate(() => {
@@ -254,6 +267,7 @@ describe('View Transitions - transition:persist on meta theme-color', () => {
254267
}) => {
255268
const page = await ComponentPersistencePage.init(playwrightPage)
256269
await page.goto('/')
270+
await page.waitForHeaderComponents()
257271

258272
// Get initial mobile-web-app-capable value
259273
const initialValue = await page.evaluate(() => {
@@ -264,8 +278,7 @@ describe('View Transitions - transition:persist on meta theme-color', () => {
264278
expect(initialValue).toBe('yes')
265279

266280
// Navigate to case studies page
267-
await page.navigateToPage('/case-studies')
268-
await page.waitForURL('**/case-studies', { timeout: 5000 })
281+
await navigateAndAwaitHydration(page, '/case-studies', '**/case-studies')
269282

270283
// Get mobile-web-app-capable value after navigation
271284
const afterNavigationValue = await page.evaluate(() => {

0 commit comments

Comments
 (0)