Skip to content
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,5 @@ npm run lint # full lint suite
## Tech Stack

Astro, TypeScript, Lit, Tailwind CSS, Turso (libSQL), Sentry, Vercel, Playwright, Vitest, MJML, Workbox, GitHub Actions

test/e2e/specs/11-accessibility/high-contrast-wcag-compliance.spec.ts
1 change: 1 addition & 0 deletions src/components/CallToAction/Newsletter/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const descriptionId = `${idBase}-description`

<newsletter-form
class="cta block print:hidden!"
role="group"
aria-labelledby={titleId}
aria-describedby={descriptionId}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ describe('NewsletterConfirmElement web component', () => {
expect(elements.expiredState.classList.contains('hidden')).toBe(false)
expect(elements.loadingState.classList.contains('hidden')).toBe(true)
expect(elements.statusAnnouncer.textContent).toBe('Confirmation link expired.')
expect(document.activeElement).toBe(elements.expiredHeading)
},
{ data: { success: false, status: 'expired' } }
)
Expand Down
22 changes: 21 additions & 1 deletion src/components/Pages/Newsletter/Confirm/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,27 @@ export class NewsletterConfirmElement extends LitElement {
}

private focusHeading(heading: HTMLElement): void {
heading.focus()
const attemptFocus = () => {
if (!heading.isConnected) {
return
}

heading.focus()
}

attemptFocus()

if (heading.ownerDocument.activeElement === heading) {
return
}

window.setTimeout(() => {
if (heading.ownerDocument.activeElement === heading) {
return
}

attemptFocus()
}, 0)
}

private hideAllStates(): void {
Expand Down
12 changes: 11 additions & 1 deletion test/e2e/helpers/cookieHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,17 @@ export async function selectTheme(page: Page, themeId: string): Promise<void> {
// Click the theme button
// Use button selector to avoid matching <html data-theme="...">
const themeButton = page.locator(`button[data-theme="${themeId}"]`)
await themeButton.click()
const viewport = page.viewportSize()
const isMobile = Boolean(viewport && viewport.width < 768)

if (isMobile) {
await themeButton.focus()
await themeButton.evaluate((button: HTMLButtonElement) => {
button.click()
})
} else {
await themeButton.click()
}

// Wait for current theme to update everywhere
const html = page.locator('html')
Expand Down
44 changes: 39 additions & 5 deletions test/e2e/helpers/fetchOverride.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,40 @@ const delay = async (delayMs: number): Promise<void> => {
await new Promise<void>(resolve => setTimeout(resolve, delayMs))
}

const isRouteAlreadyHandledError = (error: unknown): boolean => {
return error instanceof Error && error.message.includes('Route is already handled')
}

const safeContinueRoute = async (
route: Route,
overrides?: Parameters<Route['continue']>[0],
): Promise<void> => {
try {
await route.continue(overrides)
} catch (error) {
if (isRouteAlreadyHandledError(error)) {
return
}

throw error
}
}

const safeFulfillRoute = async (
route: Route,
overrides: Parameters<Route['fulfill']>[0],
): Promise<void> => {
try {
await route.fulfill(overrides)
} catch (error) {
if (isRouteAlreadyHandledError(error)) {
return
}

throw error
}
}

const withTimeout = async (promise: Promise<void>, timeoutMs: number, timeoutMessage: string): Promise<void> => {
let timeoutId: ReturnType<typeof setTimeout> | undefined
const timeoutPromise = new Promise<void>((_, reject) => {
Expand Down Expand Up @@ -130,13 +164,13 @@ const createFetchOverride = async (page: Page, options: OverrideOptions): Promis
}

if (options.mode === 'spy') {
await route.continue()
await safeContinueRoute(route)
return
}

if (options.mode === 'delay') {
await delay(options.delayMs)
await route.continue()
await safeContinueRoute(route)
return
}

Expand All @@ -145,7 +179,7 @@ const createFetchOverride = async (page: Page, options: OverrideOptions): Promis
...request.headers(),
...options.headers,
}
await route.continue({ headers: mergedHeaders })
await safeContinueRoute(route, { headers: mergedHeaders })
return
}

Expand All @@ -159,15 +193,15 @@ const createFetchOverride = async (page: Page, options: OverrideOptions): Promis

const body = typeof resolvedBody === 'string' ? resolvedBody : JSON.stringify(resolvedBody)

await route.fulfill({
await safeFulfillRoute(route, {
status: options.status ?? 200,
headers,
body,
})
return
}

await route.continue()
await safeContinueRoute(route)
}

await page.route(urlPattern, handler)
Expand Down
4 changes: 2 additions & 2 deletions test/e2e/helpers/pageObjectModels/BreadCrumbPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,9 @@ export class BreadCrumbPage extends BasePage {
async openFirstArticleDetail(options?: { navigationMode?: 'client' | 'fresh' }): Promise<void> {
await this.navigateToListingDetail({
listingPath: '/articles',
linkSelector: 'main a[href^="/articles/"]',
linkSelector: 'main a[href^="/deep-dive/"], main a[href^="/articles/"]',
minSegments: 2,
notFoundMessage: 'Could not find article detail link on /articles',
notFoundMessage: 'Could not find deep-dive or article detail link on /articles',
...options,
})
}
Expand Down
6 changes: 3 additions & 3 deletions test/e2e/specs/01-smoke/dynamic-pages.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import { BasePage, test, expect, setupConsoleErrorChecker, logConsoleErrors } from '@test/e2e/helpers'
import { wait } from '@test/e2e/helpers/waitTimeouts'

const articleLinkSelector = 'a[href*="/articles/"]'
const articleLinkSelector = 'a[href*="/deep-dive/"]'
const serviceLinkSelector = 'a[href^="/services/"]:not([href="/services/"])'
const caseStudyLinkSelector = 'a[href*="/case-studies/"]'

Expand Down Expand Up @@ -54,8 +54,8 @@ test.describe('Dynamic Pages @smoke', () => {
await expect(page.locator('main#main')).toBeVisible()
await expect(page.locator('h1[id="article-title"]')).toBeVisible()

// Verify we're on an article page (URL should match pattern)
expect(page.getCurrentUrl()).toMatch(/\/articles\/.+/)
// Verify we're on a deep-dive detail page from the articles listing
expect(page.getCurrentUrl()).toMatch(/\/deep-dive\/.+/)
})

test('@ready service detail page loads', async ({ page: playwrightPage }) => {
Expand Down
29 changes: 20 additions & 9 deletions test/e2e/specs/02-pages/articles.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,25 @@ test.describe('Articles Page', () => {
await page.goto('/articles')

await page.evaluate(selector => {
const link = document.querySelector<HTMLAnchorElement>(selector)
// eslint-disable-next-line custom-rules/enforce-centralized-events -- test-only handler in Playwright browser context
link?.addEventListener(
// Prevent Astro/client-side navigation before it can consume the click.
document.addEventListener(
'click',
event => {
const target = event.target
if (!(target instanceof Element)) {
return
}

const link = target.closest<HTMLAnchorElement>(selector)
if (!link) {
return
}

event.preventDefault()
event.stopPropagation()
event.stopImmediatePropagation()
},
{ once: true }
{ capture: true, once: true }
)
}, linkSelector)

Expand All @@ -31,12 +42,12 @@ test.describe('Articles Page', () => {
const link = card.querySelector('a')

return {
isFocused: link === document.activeElement,
hasFocusVisible: link?.matches(':focus-visible') ?? false,
opacity: afterStyles.opacity,
}
})

expect(overlayState.isFocused).toBe(true)
expect(overlayState.hasFocusVisible).toBe(false)
expect(overlayState.opacity).toBe('0')
})

Expand Down Expand Up @@ -64,10 +75,10 @@ test.describe('Articles Page', () => {
const page = await BasePage.init(playwrightPage)
await page.goto('/articles')

// Get the first article link
// Get the first deep-dive link from the articles index page
await page.click('article a')
// Should navigate to an article detail page
await page.expectUrl(/\/articles\/[^/]+/)
// Should navigate to a deep-dive detail page
await page.expectUrl(/\/deep-dive\/[^/]+/)
})

test('@ready page subtitle displays', async ({ page: playwrightPage }) => {
Expand Down
4 changes: 2 additions & 2 deletions test/e2e/specs/02-pages/tags.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ test.describe('Tags Index Page', () => {
test('@ready tag counts display', async ({ page: playwrightPage }) => {
const page = await BasePage.init(playwrightPage)
await page.goto('/tags')
// Each tag carousel should render at least one article card link
await page.expectElementVisible('a[href^="/articles/"]')
// Each tag carousel should render at least one deep-dive card link
await page.expectElementVisible('a[href^="/deep-dive/"]')
})

test('@ready responsive: mobile view renders correctly', async ({ page: playwrightPage }) => {
Expand Down
33 changes: 25 additions & 8 deletions test/e2e/specs/04-components/breadcrumbs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,18 @@ import {
} from '@test/e2e/helpers'
import { wait } from '@test/e2e/helpers/waitTimeouts'

const isMobileProject = (projectName: string): boolean => projectName.startsWith('mobile-')

test.describe('Breadcrumbs Component', () => {
test('@ready breadcrumbs display on article pages', async ({ page: playwrightPage }) => {
test('@ready breadcrumbs display on article pages', async ({ page: playwrightPage }, testInfo) => {
const page = await BreadCrumbPage.init(playwrightPage)
await page.openFirstArticleDetail()

if (isMobileProject(testInfo.project.name)) {
await page.expectElementHidden('nav[aria-label="Breadcrumbs"]')
return
}

await page.expectElementVisible('nav[aria-label="Breadcrumbs"]')
})

Expand Down Expand Up @@ -45,7 +52,9 @@ test.describe('Breadcrumbs Component', () => {
expect(firstLinkText?.toLowerCase()).toContain('home')
})

test('@ready breadcrumb links are clickable', async ({ page: playwrightPage }) => {
test('@ready breadcrumb links are clickable', async ({ page: playwrightPage }, testInfo) => {
test.skip(isMobileProject(testInfo.project.name), 'Article breadcrumbs are intentionally hidden on mobile content pages')

const page = await BreadCrumbPage.init(playwrightPage)
await page.openFirstArticleDetail()

Expand All @@ -72,12 +81,15 @@ test.describe('Breadcrumbs Component', () => {
await page.expectUrlContains('localhost:4321/')
})

test('@ready current page is not a link', async ({ page: playwrightPage }) => {
test('@ready current page is not a link', async ({ page: playwrightPage }, testInfo) => {
const page = await BreadCrumbPage.init(playwrightPage)
await page.openFirstArticleDetail()

// Last item should have aria-current="page" on the span, not be a link
await page.expectElementVisible('nav[aria-label="Breadcrumbs"] li:last-child span[aria-current="page"]')
if (isMobileProject(testInfo.project.name)) {
await page.expectElementHidden('nav[aria-label="Breadcrumbs"] li:last-child span[aria-current="page"]')
} else {
await page.expectElementVisible('nav[aria-label="Breadcrumbs"] li:last-child span[aria-current="page"]')
}

// Verify no link in last item
const linkCount = await page.countElements('nav[aria-label="Breadcrumbs"] li:last-child a')
Expand All @@ -95,14 +107,19 @@ test.describe('Breadcrumbs Component', () => {
expect(separatorCount).toBeGreaterThan(0)
})

test('@ready breadcrumbs use proper ARIA', async ({ page: playwrightPage }) => {
test('@ready breadcrumbs use proper ARIA', async ({ page: playwrightPage }, testInfo) => {
const page = await BreadCrumbPage.init(playwrightPage)
await page.openFirstArticleDetail()

await page.expectElementVisible('nav[aria-label="Breadcrumbs"]')
if (isMobileProject(testInfo.project.name)) {
await page.expectElementHidden('nav[aria-label="Breadcrumbs"]')
} else {
await page.expectElementVisible('nav[aria-label="Breadcrumbs"]')
}

// Should contain ordered list
await page.expectElementVisible('nav[aria-label="Breadcrumbs"] ol')
const orderedListCount = await page.countElements('nav[aria-label="Breadcrumbs"] ol')
expect(orderedListCount).toBeGreaterThan(0)
})

test('@ready breadcrumbs are responsive', async ({ page: playwrightPage }) => {
Expand Down
14 changes: 12 additions & 2 deletions test/e2e/specs/04-components/carousel.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,18 @@
import { BasePage, test, expect } from '@test/e2e/helpers'
import { EvaluationError } from '@test/errors'
import { waitForAnimationFrames } from '@test/e2e/helpers/waitHelpers'
import type { Page } from '@playwright/test'
import type { Page, TestInfo } from '@playwright/test'

const selectors = {
slider: 'carousel-slider[data-carousel]',
prev: '[data-carousel-prev]',
next: '[data-carousel-next]',
dots: '[data-carousel-pagination] button',
pagination: '[data-carousel-pagination]',
}

const isMobileProject = (testInfo: TestInfo): boolean => testInfo.project.name.startsWith('mobile-')

async function setupCarouselTestPage(playwrightPage: Page): Promise<BasePage> {
const page = await BasePage.init(playwrightPage)
await page.page.emulateMedia({ reducedMotion: 'no-preference' })
Expand Down Expand Up @@ -114,10 +117,17 @@ test.describe('Carousel Component', () => {
expect(afterNext).not.toBe(initialIndex)
})

test('pagination dots jump to selected slide', async ({ page: playwrightPage }) => {
test('pagination dots jump to selected slide', async ({ page: playwrightPage }, testInfo) => {
const page = await setupCarouselTestPage(playwrightPage)
const slider = page.locator(selectors.slider).first()
const pagination = slider.locator(selectors.pagination)
const dots = slider.locator(selectors.dots)

if (isMobileProject(testInfo)) {
await expect(pagination).toBeHidden()
return
}

const dotTotal = await dots.count()

expect(dotTotal).toBeGreaterThan(2)
Expand Down
10 changes: 9 additions & 1 deletion test/e2e/specs/04-components/diagram.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

import { BasePage, expect, test } from '@test/e2e/helpers'

const isMobileProject = (projectName: string): boolean => projectName.startsWith('mobile-')

const fixturePath = '/testing/diagram'
const figureSelector = 'figure'
const detailsSelector = `${figureSelector} details`
Expand Down Expand Up @@ -62,9 +64,15 @@ test.describe('Diagram Component', () => {
await expect(page.locator(detailsSelector)).not.toHaveAttribute('open', '')
})

test('@ready opens the expanded image dialog and closes it with Escape', async ({ page: playwrightPage }) => {
test('@ready opens the expanded image dialog and closes it with Escape', async ({ page: playwrightPage }, testInfo) => {
const page = await loadDiagramFixture(playwrightPage)

if (isMobileProject(testInfo.project.name)) {
await expect(page.locator(imageTriggerSelector)).toBeHidden()
await expect(page.locator(imageDialogSelector)).not.toBeVisible()
return
}

await page.locator(imageTriggerSelector).click()

await expect(page.locator(imageDialogSelector)).toBeVisible()
Expand Down
Loading
Loading