Skip to content
Merged
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
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Personal website of Daniel Castro. Static-first Next.js app, hosted on Vercel.
- Biome (lint and format)
- MDX loaded from `content/` in `lib/content` (gray-matter, Zod, `next-mdx-remote`)
- Vitest and Testing Library
- Playwright (Chromium)
- Playwright (`chromium`, `mobile`, and `webkit`)
- Vercel

## Getting started
Expand All @@ -31,16 +31,18 @@ Environment variables are optional; the site runs locally without them. `NEXT_PU
- `pnpm build` — production build
- `pnpm start` — serve the production build
- `pnpm test` — unit tests (Vitest)
- `pnpm test:e2e` — E2E smoke tests (Playwright, Chromium)
- `pnpm test:e2e` — Playwright. Untagged tests run in `chromium` (Desktop Chrome). `@mobile` runs in `mobile` (Pixel 7). `@smoke` runs in `webkit` (Desktop Safari).
- `pnpm lint` — Biome check
- `pnpm format` — Biome check and write
- `pnpm typecheck` — `tsc --noEmit`

Install Playwright's Chromium browser once before the first E2E run:
Install Chromium and WebKit once before the first local E2E run. CI uses the Playwright image, which already includes the browsers, and does not install them:

```sh
pnpm exec playwright install
pnpm exec playwright install chromium webkit
pnpm test:e2e
pnpm test:e2e -- --project=mobile
pnpm test:e2e -- --project=webkit
```

`pnpm test:e2e` builds and starts the app (`pnpm build && pnpm start`) via Playwright's `webServer`.
Expand All @@ -52,7 +54,7 @@ pnpm test:e2e
- `public/` — static files, including `media/` for posts, personal photos, icons, illustrations, and documents
- `lib/` — shared TypeScript modules
- `components/` — UI by responsibility: `views/` (pages), `layout/` (shell, header, footer, container), `mdx/` (MDX blocks), `ui/` (controls and icons). Providers and route infra stay at the root (`theme-provider`, `interface-language-provider`, `json-ld`, `route-error-boundary`).
- `e2e/` — Playwright smoke tests
- `e2e/` — Playwright tests (`chromium`, `@mobile`, `@smoke`)

`@/foo` resolves to `<repo>/foo` via `compilerOptions.paths` in `tsconfig.json`. Example: `@/lib/site` → `lib/site.ts`. There is no `~/*` alias.

Expand Down
58 changes: 58 additions & 0 deletions e2e/axe.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { expect, type Page, test } from '@playwright/test'
import { collectAxeViolations } from './helpers/axe'
import { guardRoutes } from './helpers/guard-routes'

const themes = ['dark', 'light'] as const

type AxeViolation = Awaited<ReturnType<typeof collectAxeViolations>>[number]

function formatViolations(
route: string,
theme: (typeof themes)[number],
violations: AxeViolation[],
): string {
return violations
.flatMap((violation) =>
violation.nodes.map((node) => {
const selector = node.target
.map((part) => (Array.isArray(part) ? part.join(' ') : part))
.join(' ')
return `${violation.id} on ${route} (${theme}): ${selector}`
}),
)
.join('\n')
}

async function openThemedRoute(
page: Page,
route: string,
theme: (typeof themes)[number],
) {
await page.addInitScript((value) => {
localStorage.setItem('theme', value)
}, theme)
await page.goto(route)

const html = page.locator('html')
if (theme === 'dark') {
await expect(html).toHaveClass(/\bdark\b/, { timeout: 5000 })
} else {
await expect(html).not.toHaveClass(/\bdark\b/)
}

await expect(page.getByRole('main')).toBeVisible()
await page.evaluate(async () => {
await document.fonts.ready
})
}

for (const route of guardRoutes) {
for (const theme of themes) {
test(`${route} ${theme} has no axe violations`, async ({ page }) => {
await openThemedRoute(page, route, theme)

const violations = await collectAxeViolations(page)
expect(formatViolations(route, theme, violations)).toBe('')
})
}
}
34 changes: 34 additions & 0 deletions e2e/console.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { expect, test } from '@playwright/test'
import { type ConsoleIssue, collectConsoleErrors } from './helpers/console'
import { guardRoutes } from './helpers/guard-routes'

/**
* Chromium logs the 404 document as
* "Failed to load resource: the server responded with a status of 404".
* That message is ignored only on `/qualquer`, and only when `location().url`
* is that document. Any other 404, on any route, fails the test.
*/
function isAllowedDocument404(route: string, issue: ConsoleIssue): boolean {
if (route !== '/qualquer' || !issue.locationUrl) {
return false
}

const pathname = URL.parse(issue.locationUrl)?.pathname
return (
pathname === '/qualquer' &&
issue.text.includes('Failed to load resource') &&
issue.text.includes('404')
)
}

for (const route of guardRoutes) {
test(`${route} has no console errors`, async ({ page }) => {
const issues = collectConsoleErrors(page)
await page.goto(route)
await expect(page.getByRole('main')).toBeVisible()

expect(
issues.filter((issue) => !isAllowedDocument404(route, issue)),
).toEqual([])
})
}
18 changes: 18 additions & 0 deletions e2e/helpers/axe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import AxeBuilder from '@axe-core/playwright'
import type { Page } from '@playwright/test'

const wcagTags = [
'wcag2a',
'wcag2aa',
'wcag21a',
'wcag21aa',
'wcag22aa',
] as const

/** WCAG A/AA violations. `incomplete` results are left out of the assertion. */
export function collectAxeViolations(page: Page) {
return new AxeBuilder({ page })
.withTags([...wcagTags])
.analyze()
.then((results) => results.violations)
}
21 changes: 16 additions & 5 deletions e2e/helpers/console.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,27 @@
import type { Page } from '@playwright/test'

export function collectConsoleErrors(page: Page): string[] {
const consoleErrors: string[] = []
const hydrationWarning = /hydration|did not match|server-rendered html/i

export type ConsoleIssue = {
text: string
locationUrl?: string
}

export function collectConsoleErrors(page: Page): ConsoleIssue[] {
const consoleErrors: ConsoleIssue[] = []

page.on('console', (msg) => {
if (msg.type() === 'error') {
consoleErrors.push(msg.text())
const text = msg.text()
if (
msg.type() === 'error' ||
(msg.type() === 'warning' && hydrationWarning.test(text))
) {
consoleErrors.push({ text, locationUrl: msg.location().url })
}
})

page.on('pageerror', (error) => {
consoleErrors.push(error.message)
consoleErrors.push({ text: error.message })
})

return consoleErrors
Expand Down
18 changes: 18 additions & 0 deletions e2e/helpers/guard-routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/** Shared by the axe and console guards. Overflow unions this list with every post. */
export const guardRoutes = [
'/',
'/en',
'/blog',
'/blog?view=grid',
'/en/blog',
'/about',
'/en/about',
'/community',
'/en/community',
'/privacy',
'/alunos',
'/qualquer',
'/blog/postgresql-e-pgadmin-com-docker-compose',
'/blog/16-livros-para-desenvolvedores-ler-em-2022',
'/en/blog/postgresql-e-pgadmin-com-docker-compose',
] as const
37 changes: 37 additions & 0 deletions e2e/helpers/overflow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { expect, type Page } from '@playwright/test'
import { guardRoutes } from './guard-routes'
import { getAllBlogPosts } from './posts'

export function overflowRoutes(): string[] {
const postRoutes = getAllBlogPosts('overflow').map(
(post) => `/blog/${post.slug}`,
)

return [...new Set<string>([...guardRoutes, ...postRoutes])]
}

export async function openForOverflowCheck(page: Page, route: string) {
// `load` also waits for images. One post never reaches it in CI, which
// hides the scrollWidth check. Layout is ready with the document and fonts.
await page.goto(route, { waitUntil: 'domcontentloaded' })
await expect(page.getByRole('main')).toBeVisible()
await page.evaluate(async () => {
await document.fonts.ready
})
}

export async function expectNoHorizontalOverflow(
page: Page,
route: string,
width: number,
) {
const { scrollWidth, clientWidth } = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
}))

expect(
scrollWidth,
`${route} at ${width}px overflows: scrollWidth ${scrollWidth}, clientWidth ${clientWidth}`,
).toBe(clientWidth)
}
4 changes: 4 additions & 0 deletions e2e/helpers/posts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ function loadPosts(context: string): PostSummary[] {
return cachedPosts
}

export function getAllBlogPosts(context: string): PostSummary[] {
return loadPosts(context)
}

export function getNewestBlogPost(context: string): PostSummary {
const posts = loadPosts(context)
const newest = posts[0]
Expand Down
108 changes: 56 additions & 52 deletions e2e/mobile-menu.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { expect, type Page, test } from '@playwright/test'

test.use({ viewport: { width: 390, height: 844 } })

function focusedRegion(page: Page) {
return page.evaluate(() => {
const current = document.activeElement
Expand All @@ -28,68 +26,74 @@ function focusedRegion(page: Page) {
})
}

test.beforeEach(async ({ page }) => {
await page.goto('/')
})
test.describe('mobile menu', { tag: '@mobile' }, () => {
test.use({ viewport: { width: 390, height: 844 } })

test('Enter and Space open the menu on the Blog link', async ({ page }) => {
const trigger = page.getByRole('button', { name: 'Menu' })
await trigger.focus()
await page.keyboard.press('Enter')
test.beforeEach(async ({ page }) => {
await page.goto('/')
})

const blog = page
.getByRole('navigation', { name: 'Navegação principal' })
.getByRole('link', { name: 'Blog' })
await expect(blog).toBeFocused()
test('Enter and Space open the menu on the Blog link', async ({ page }) => {
const trigger = page.getByRole('button', { name: 'Menu' })
await trigger.focus()
await page.keyboard.press('Enter')

await page.keyboard.press('Escape')
await expect(trigger).toBeFocused()
const blog = page
.getByRole('navigation', { name: 'Navegação principal' })
.getByRole('link', { name: 'Blog' })
await expect(blog).toBeFocused()

await page.keyboard.press('Space')
await expect(blog).toBeFocused()
})
await page.keyboard.press('Escape')
await expect(trigger).toBeFocused()

test('Tab and Shift+Tab stay in the header and the menu', async ({ page }) => {
const trigger = page.getByRole('button', { name: 'Menu' })
await trigger.focus()
await page.keyboard.press('Enter')
await page.keyboard.press('Space')
await expect(blog).toBeFocused()
})

const blog = page
.getByRole('navigation', { name: 'Navegação principal' })
.getByRole('link', { name: 'Blog' })
await expect(blog).toBeFocused()
test('Tab and Shift+Tab stay in the header and the menu', async ({
page,
}) => {
const trigger = page.getByRole('button', { name: 'Menu' })
await trigger.focus()
await page.keyboard.press('Enter')

await page.keyboard.press('Shift+Tab')
expect(await focusedRegion(page)).toBe('header')
const blog = page
.getByRole('navigation', { name: 'Navegação principal' })
.getByRole('link', { name: 'Blog' })
await expect(blog).toBeFocused()

for (let step = 0; step < 10; step += 1) {
await page.keyboard.press('Shift+Tab')
const region = await focusedRegion(page)
expect(['header', 'menu', 'body'], region).toContain(region)
}
expect(await focusedRegion(page)).toBe('header')

await blog.focus()
await expect(blog).toBeFocused()
await page.keyboard.press('Tab')
expect(await focusedRegion(page)).toBe('menu')
for (let step = 0; step < 10; step += 1) {
await page.keyboard.press('Shift+Tab')
const region = await focusedRegion(page)
expect(['header', 'menu', 'body'], region).toContain(region)
}

for (let step = 0; step < 10; step += 1) {
await blog.focus()
await expect(blog).toBeFocused()
await page.keyboard.press('Tab')
const region = await focusedRegion(page)
expect(['header', 'menu', 'body'], region).toContain(region)
}
})
expect(await focusedRegion(page)).toBe('menu')

test('Escape returns focus to the trigger and clears inert', async ({
page,
}) => {
const trigger = page.getByRole('button', { name: 'Menu' })
await trigger.focus()
await page.keyboard.press('Enter')
await expect(page.locator('[inert]').first()).toBeAttached()
for (let step = 0; step < 10; step += 1) {
await page.keyboard.press('Tab')
const region = await focusedRegion(page)
expect(['header', 'menu', 'body'], region).toContain(region)
}
})

await page.keyboard.press('Escape')
await expect(trigger).toBeFocused()
await expect(trigger).toHaveAttribute('aria-expanded', 'false')
await expect(page.locator('[inert]')).toHaveCount(0)
test('Escape returns focus to the trigger and clears inert', async ({
page,
}) => {
const trigger = page.getByRole('button', { name: 'Menu' })
await trigger.focus()
await page.keyboard.press('Enter')
await expect(page.locator('[inert]').first()).toBeAttached()

await page.keyboard.press('Escape')
await expect(trigger).toBeFocused()
await expect(trigger).toHaveAttribute('aria-expanded', 'false')
await expect(page.locator('[inert]')).toHaveCount(0)
})
})
Loading
Loading