Skip to content

Commit 7021b58

Browse files
committed
Work on removing flakiness from E2E test suite, renumbering to remove emails category
1 parent 26ed7ea commit 7021b58

42 files changed

Lines changed: 306 additions & 78 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎_TODO.md‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,33 @@ Right now we're using string literals to define HTML email templates for site ma
5656
Vercel AI Gateway, maybe could use for a chatbot:
5757

5858
https://vercel.com/kevin-browns-projects-dd474f73/astro-webstackbuilders-com/ai-gateway
59+
60+
1. Firefox/WebKit locator.setChecked(...) “did not change its state”
61+
62+
With your markup, the checkbox is sr-only and the visual switch is a separate element (peer-based). In Firefox/WebKit, a forced click on a clipped/visually-hidden checkbox can “click” without producing the native toggle behavior Playwright expects (or the component’s JS is not wired to the checkbox click in a way that actually flips the DOM checked state).
63+
64+
Most deterministic alternatives (conceptually):
65+
66+
- Don’t uncheck via the hidden input. Use the “Deny All” control (it exists as #consent-deny-all), then click “Allow All”, then assert all are checked. This tests the same behavior (“Allow All enables every category”) without the fragile intermediate toggle interaction.
67+
68+
- If you truly need to uncheck just one category, do it by setting state via JS (checked = false + dispatch input/change) because that bypasses the click/geometry issues and targets the state machine the component is likely listening to.
69+
70+
2. mobile-safari consent banner: waitForPageLoad() still times out (15s)
71+
72+
Important observation: in consentBanner.spec.ts, you already wait on a much more direct readiness signal right after waitForPageLoad():
73+
74+
consent-banner.isInitialized === true
75+
So the likely story here is: waitForPageLoad() is unnecessary for these tests, and when it flakes it blocks the entire suite.
76+
77+
3. mobile-safari store persistence beforeEach timeouts + Edge footer page.goto('/') timeouts
78+
79+
These are pure navigation-level failures (timeouts in page.goto or in a beforeEach that presumably can’t get through its startup navigation). The usual causes are:
80+
81+
a global readiness wait that never resolves (which can cascade into beforeEach timing out)
82+
83+
- Two questions so we pick the right fixes
84+
85+
For the consent “Allow All enables every category” test: are you okay with changing the test to use #consent-deny-all instead of unchecking a single toggle (still verifies Allow All enables everything, but avoids the hidden-switch interaction entirely)?
86+
87+
For the navigation timeouts (mobile-safari + Edge): were these runs executed while npm run dev was definitely running and stable, or is it possible the server wasn’t up / got interrupted?
88+

‎playwright.config.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ const buildWebServerEnv = () => {
3535
return env
3636
}
3737

38-
const workersHighParallel = process.env['GITHUB_ACTIONS'] ? 1 : '75%'
38+
const workersHighParallel = process.env['GITHUB_ACTIONS'] ? 1 : 2
3939
const testMatchHighParallel = '**/*.spec.ts'
4040
const testIgnoreHighParallel = [
4141
'03-forms/**/*.spec.ts',
@@ -66,7 +66,7 @@ export default defineConfig({
6666
forbidOnly: !!process.env['GITHUB_ACTIONS'],
6767
/** Retry on CI only */
6868
retries: process.env['GITHUB_ACTIONS'] ? 2 : 0,
69-
/** Opt out of parallel tests on CI. */
69+
/** Opt out of high parallelism in CI-mode runs. */
7070
workers: workersHighParallel,
7171
/** Only run @ready tests in CI, all tests locally */
7272
...(process.env['GITHUB_ACTIONS'] ? { grep: /@ready/ } : {}),

‎test/e2e/db/helpers.ts‎

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -230,8 +230,24 @@ export const deleteDsarRequestById = async (id: string) => {
230230

231231
export const deleteConsentRecordsBySubjectId = async (dataSubjectId: string) => {
232232
const libsql = getLibsqlClient()
233-
await libsql.execute({
234-
sql: `DELETE FROM consentEvents WHERE dataSubjectId = ?`,
235-
args: [dataSubjectId],
236-
})
233+
234+
const maxAttempts = 5
235+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
236+
try {
237+
await libsql.execute({
238+
sql: `DELETE FROM consentEvents WHERE dataSubjectId = ?`,
239+
args: [dataSubjectId],
240+
})
241+
return
242+
} catch (error) {
243+
const message = error instanceof Error ? error.message : String(error)
244+
const isBusy = message.includes('SQLITE_BUSY') || message.toLowerCase().includes('database is locked')
245+
if (!isBusy || attempt === maxAttempts) {
246+
throw error
247+
}
248+
249+
// Yield briefly to let another worker release its write lock.
250+
await new Promise<void>((resolve) => setTimeout(resolve, 50 * attempt))
251+
}
252+
}
237253
}

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

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,27 @@ export class BasePage extends BuiltInsPage {
130130
window.__astroPageLoadCounter = 0
131131
}
132132

133+
// `astro:page-load` is primarily a View Transitions signal.
134+
// Some browsers/environments may not reliably dispatch it on a fresh load.
135+
// We also bump the counter on full document loads so `waitForPageLoad()` works
136+
// for both navigation modes.
137+
let fullLoadCountedForDocument = false
138+
const bumpFullLoadCounter = () => {
139+
if (fullLoadCountedForDocument) return
140+
fullLoadCountedForDocument = true
141+
window.__astroPageLoadCounter = (window.__astroPageLoadCounter ?? 0) + 1
142+
}
143+
144+
if (typeof window !== 'undefined') {
145+
window.addEventListener('load', bumpFullLoadCounter, { passive: true })
146+
// Covers BFCache restores where `load` may not fire.
147+
window.addEventListener('pageshow', bumpFullLoadCounter, { passive: true })
148+
}
149+
150+
if (typeof document !== 'undefined' && document.readyState === 'complete') {
151+
bumpFullLoadCounter()
152+
}
153+
133154
if (!window.__astroPageLoadListenerAttached) {
134155
const registerAstroPageLoadListener = () => {
135156
if (window.__astroPageLoadListenerAttached) return
@@ -305,7 +326,7 @@ export class BasePage extends BuiltInsPage {
305326
*/
306327
async waitForPageLoad(options?: { requireNext?: boolean; timeout?: number }): Promise<void> {
307328
const requireNext = options?.requireNext ?? false
308-
const timeout = options?.timeout ?? wait.defaultWait
329+
const timeout = options?.timeout ?? wait.navigation
309330
const currentCount = await this._page.evaluate(() => window.__astroPageLoadCounter ?? 0)
310331

311332
if (!requireNext && currentCount > this.lastAstroPageLoadCount) {
@@ -435,6 +456,14 @@ export class BasePage extends BuiltInsPage {
435456
await expect(this._page.locator('h1').first()).toBeVisible()
436457
}
437458

459+
/**
460+
* Verify homepage hero section is present and visible
461+
*/
462+
async expectHeroSection(): Promise<void> {
463+
await expect(this._page.locator('section[aria-labelledby="home-hero-title"]')).toBeVisible()
464+
await expect(this._page.locator('#home-hero-title')).toBeVisible()
465+
}
466+
438467
/**
439468
* Check if page has specific heading
440469
*/

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export class BuiltInsPage {
4040
* via `afterGoto` (without re-implementing retry logic everywhere).
4141
*/
4242
async goto(path: string, options?: BuiltInsGotoOptions): Promise<null | Response> {
43-
const requestedTimeout = options?.timeout ?? wait.defaultWait
43+
const requestedTimeout = options?.timeout ?? wait.navigation
4444
const waitUntil = options?.waitUntil ?? 'domcontentloaded'
4545

4646
const navigate = async (timeout: number) => {
@@ -58,7 +58,7 @@ export class BuiltInsPage {
5858
const message = error instanceof Error ? error.message : String(error)
5959
if (message.includes('ERR_ABORTED')) {
6060
response = await navigate(requestedTimeout)
61-
} else if (!options?.timeout && message.includes('Timeout')) {
61+
} else if (!options?.timeout && message.includes('Timeout') && requestedTimeout < wait.navigation) {
6262
// Allow a single retry with a longer timeout to absorb slow prerender navigations
6363
response = await navigate(wait.navigation)
6464
} else {

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
*/
55
import { type Page, expect } from '@playwright/test'
66
import { BasePage } from '@test/e2e/helpers'
7+
import { wait } from '@test/e2e/helpers/waitTimeouts'
78

89
export class NewsletterPage extends BasePage {
910
private readonly subscribeActionEndpoint = '/_actions/newsletter/subscribe'
@@ -33,7 +34,7 @@ export class NewsletterPage extends BasePage {
3334
*/
3435
async navigateToNewsletterForm(): Promise<void> {
3536
await this.goto(this.fixturePath)
36-
await this.waitForLoadState('networkidle') // Ensure all scripts are loaded
37+
await this.page.waitForSelector(this.formSelector, { state: 'visible', timeout: wait.navigation })
3738
await this.expectNewsletterForm()
3839
}
3940

@@ -133,6 +134,18 @@ export class NewsletterPage extends BasePage {
133134
await expect(this.page.locator(this.messageSelector)).toContainText(text)
134135
}
135136

137+
/**
138+
* Assert the newsletter form reached a successful confirmation state.
139+
* Copy can vary; this checks semantics and avoids coupling tests to exact wording.
140+
*/
141+
async expectSuccessConfirmation(): Promise<void> {
142+
const message = this.page.locator(this.messageSelector)
143+
144+
await expect(message).toBeVisible({ timeout: wait.defaultWait })
145+
await expect(message).not.toContainText(/sending confirmation email/i, { timeout: wait.polling })
146+
await expect(message).toContainText(/check your email|confirmation email|confirm your subscription/i)
147+
}
148+
136149
/**
137150
* Verify loading spinner is visible
138151
*/
@@ -150,7 +163,7 @@ export class NewsletterPage extends BasePage {
150163
/**
151164
* Wait for the spinner to enter the loading state at least once
152165
*/
153-
async waitForSpinnerLoadingState(timeout = 2000): Promise<void> {
166+
async waitForSpinnerLoadingState(timeout = wait.quickAssert): Promise<void> {
154167
await this.page.waitForFunction(
155168
selector => {
156169
const spinner = document.querySelector(selector)

‎test/e2e/specs/03-forms/newsletter-subscription.spec.ts‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ test.describe('Newsletter Subscription Form', () => {
2121
await newsletterPage.checkGdprConsent()
2222
await newsletterPage.submitForm()
2323

24-
// Should show confirmation message (using partial match to handle variations)
25-
await newsletterPage.expectMessageContains('check your email')
24+
// Should show confirmation message (wording varies)
25+
await newsletterPage.expectSuccessConfirmation()
2626
})
2727

2828
test('@ready form rejects invalid email format', async ({ page: playwrightPage }) => {
@@ -119,8 +119,7 @@ test.describe('Newsletter Subscription Form', () => {
119119
await newsletterPage.checkGdprConsent()
120120
await newsletterPage.submitForm()
121121

122-
// Wait for success message (updated to match actual API response)
123-
await newsletterPage.expectMessageContains('Please check your email to confirm your subscription')
122+
await newsletterPage.expectSuccessConfirmation()
124123

125124
// Verify form is cleared
126125
await newsletterPage.expectFormReset()
@@ -167,7 +166,7 @@ test.describe('Newsletter Subscription Form', () => {
167166
await newsletterPage.checkGdprConsent()
168167
await newsletterPage.submitForm()
169168

170-
await newsletterPage.expectMessageContains('check your email')
169+
await newsletterPage.expectSuccessConfirmation()
171170
})
172171

173172
test('@ready API error preserves form state and surfaces message', async ({ page: playwrightPage }) => {

‎test/e2e/specs/04-components/breadcrumbs.spec.ts‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
expect,
1010
test,
1111
} from '@test/e2e/helpers'
12+
import { wait } from '@test/e2e/helpers/waitTimeouts'
1213

1314
test.describe('Breadcrumbs Component', () => {
1415
test('@ready breadcrumbs display on article pages', async ({ page: playwrightPage }) => {
@@ -48,8 +49,25 @@ test.describe('Breadcrumbs Component', () => {
4849
const page = await BreadCrumbPage.init(playwrightPage)
4950
await page.openFirstArticleDetail()
5051

51-
await page.click('nav[aria-label="Breadcrumbs"] a')
52-
await page.waitForLoadState('networkidle')
52+
const supportsViewTransitions = await page.evaluate(() => {
53+
if (typeof document === 'undefined') {
54+
return false
55+
}
56+
return typeof document.startViewTransition === 'function'
57+
})
58+
59+
if (supportsViewTransitions) {
60+
const waitForLoad = page.waitForPageLoad({ requireNext: true, timeout: wait.navigation })
61+
await page.click('nav[aria-label="Breadcrumbs"] a')
62+
await waitForLoad
63+
} else {
64+
const navigationPromise = page.page.waitForURL((url: URL) => url.pathname === '/', {
65+
waitUntil: 'domcontentloaded',
66+
timeout: wait.navigation,
67+
})
68+
await page.click('nav[aria-label="Breadcrumbs"] a')
69+
await navigationPromise
70+
}
5371

5472
await page.expectUrlContains('localhost:4321/')
5573
})

‎test/e2e/specs/04-components/consentBanner.spec.ts‎

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -88,16 +88,16 @@ test.describe('Consent Banner', () => {
8888
*/
8989
test.beforeEach(async ({ page: playwrightPage, context }) => {
9090
const page = await BasePage.init(playwrightPage)
91-
// Clear all cookies and storage before navigation
91+
// Clear cookies first so our initial origin visit is clean
9292
await context.clearCookies()
9393

94-
// Navigate to page without auto-dismissing the consent banner
94+
// Establish origin so we can clear localStorage/sessionStorage deterministically
9595
await page.goto('/', { skipCookieDismiss: true, timeout: wait.navigation })
9696
await resetConsentState(page)
97+
await context.clearCookies()
9798

98-
// Reload so the modal logic re-runs with a clean browser state across all engines
99-
await page.reload({ waitUntil: 'domcontentloaded' })
100-
await page.waitForLoadState('networkidle')
99+
// Fresh navigation so consent logic runs with a fully reset state
100+
await page.goto('/', { skipCookieDismiss: true, timeout: wait.navigation })
101101
await page.waitForPageLoad()
102102

103103
// Wait for consent banner custom element to be initialized
@@ -148,7 +148,6 @@ test.describe('Consent Banner', () => {
148148
await acceptAllCookies(page)
149149

150150
await page.reload({ waitUntil: 'domcontentloaded' })
151-
await page.waitForLoadState('networkidle')
152151
await removeViteErrorOverlay(page)
153152

154153
await expect(cookieBanner).toBeHidden()
@@ -161,7 +160,6 @@ test.describe('Consent Banner', () => {
161160
await acceptAllCookies(page)
162161

163162
await page.goto('/about', { timeout: wait.navigation })
164-
await page.waitForLoadState('networkidle')
165163
await removeViteErrorOverlay(page)
166164

167165
await expect(cookieBanner).toBeHidden()

‎test/e2e/specs/04-components/consentPreferences.spec.ts‎

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,24 @@ const CONSENT_PAGE_PATH = '/consent'
1717

1818
const toggleLabel = (checkboxId: string): string => `[data-consent-toggle="${checkboxId}"]`
1919

20+
async function toggleCheckboxViaKeyboard(page: BasePage, checkboxId: string): Promise<void> {
21+
const selector = `#${checkboxId}`
22+
const checkbox = page.locator(selector)
23+
24+
await checkbox.scrollIntoViewIfNeeded()
25+
await checkbox.focus()
26+
27+
await expect
28+
.poll(async () => {
29+
return await page.evaluate((id: string) => {
30+
return document.activeElement?.id === id
31+
}, checkboxId)
32+
})
33+
.toBe(true)
34+
35+
await page.keyboard.press('Space')
36+
}
37+
2038
const decodeAstroActionJson = (raw: unknown): unknown => {
2139
if (!Array.isArray(raw) || raw.length === 0) {
2240
return raw
@@ -103,7 +121,6 @@ test.describe('Consent Preferences Component', () => {
103121

104122
await context.clearCookies()
105123
await page.goto(CONSENT_PAGE_PATH, { timeout: wait.navigation })
106-
await playwrightPage.waitForLoadState('networkidle')
107124
await removeViteErrorOverlay(page)
108125
await waitForConsentPreferences(page)
109126
})
@@ -212,9 +229,9 @@ test.describe('Consent Preferences Component', () => {
212229

213230
await page.locator(ALLOW_ALL_BUTTON).click()
214231

215-
await page.locator(toggleLabel('analytics-cookies')).click()
216-
await page.locator(toggleLabel('functional-cookies')).click()
217-
await page.locator(toggleLabel('marketing-cookies')).click()
232+
await toggleCheckboxViaKeyboard(page, 'analytics-cookies')
233+
await toggleCheckboxViaKeyboard(page, 'functional-cookies')
234+
await toggleCheckboxViaKeyboard(page, 'marketing-cookies')
218235

219236
await expect(analyticsCheckbox).not.toBeChecked()
220237
await expect(functionalCheckbox).not.toBeChecked()
@@ -235,7 +252,10 @@ test.describe('Consent Preferences Component', () => {
235252
const marketingCheckbox = page.locator('#marketing-cookies')
236253

237254
await page.locator(ALLOW_ALL_BUTTON).click()
238-
await page.locator(toggleLabel('functional-cookies')).click()
255+
256+
// Toggle via keyboard to mirror accessible interaction and avoid browser differences
257+
// around clicking sr-only inputs.
258+
await toggleCheckboxViaKeyboard(page, 'functional-cookies')
239259
await expect(functionalCheckbox).not.toBeChecked()
240260

241261
await page.locator(ALLOW_ALL_BUTTON).click()
@@ -245,4 +265,24 @@ test.describe('Consent Preferences Component', () => {
245265
await expect(marketingCheckbox).toBeChecked()
246266

247267
})
268+
269+
test('@ready label click toggles functional switch (chromium only)', async ({ page: playwrightPage }, testInfo) => {
270+
if (testInfo.project.name !== 'chromium') {
271+
test.skip()
272+
}
273+
274+
const page = await BasePage.init(playwrightPage)
275+
await waitForConsentPreferences(page)
276+
277+
const functionalCheckbox = page.locator('#functional-cookies')
278+
279+
await page.locator(ALLOW_ALL_BUTTON).click()
280+
await expect(functionalCheckbox).toBeChecked()
281+
282+
await page.locator(toggleLabel('functional-cookies')).click()
283+
await expect(functionalCheckbox).not.toBeChecked()
284+
285+
await page.locator(toggleLabel('functional-cookies')).click()
286+
await expect(functionalCheckbox).toBeChecked()
287+
})
248288
})

0 commit comments

Comments
 (0)