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
File renamed without changes.
6 changes: 6 additions & 0 deletions nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ export default defineNuxtConfig({
'@nuxtjs/color-mode',
],

$test: {
debug: {
hydration: true,
},
},

colorMode: {
preference: 'system',
fallback: 'dark',
Expand Down
124 changes: 124 additions & 0 deletions test/e2e/hydration.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import type { Page } from '@playwright/test'
import { expect, test } from './test-utils'

const PAGES = [
'/',
'/about',
'/settings',
'/privacy',
'/compare',
'/search',
'/package/nuxt',
'/search?q=vue',
] as const

// ---------------------------------------------------------------------------
// Test matrix
//
// For each user setting, we test two states across all pages:
// 1. undefined — empty localStorage, the default/fresh-install experience
// 2. a non-default value — verifies hydration still works when the user has
// changed that setting from its default
// ---------------------------------------------------------------------------

test.describe('Hydration', () => {
test.describe('no user settings (empty localStorage)', () => {
for (const page of PAGES) {
test(`${page}`, async ({ goto, hydrationErrors }) => {
await goto(page, { waitUntil: 'hydration' })

expect(hydrationErrors).toEqual([])
})
}
})

// Default: "system" → test explicit "dark"
test.describe('color mode: dark', () => {
for (const page of PAGES) {
test(`${page}`, async ({ page: pw, goto, hydrationErrors }) => {
await injectLocalStorage(pw, { 'npmx-color-mode': 'dark' })
await goto(page, { waitUntil: 'hydration' })

expect(hydrationErrors).toEqual([])
})
}
})

// Default: null → test "violet"
test.describe('accent color: violet', () => {
for (const page of PAGES) {
test(`${page}`, async ({ page: pw, goto, hydrationErrors }) => {
await injectLocalStorage(pw, {
'npmx-settings': JSON.stringify({ accentColorId: 'violet' }),
})
await goto(page, { waitUntil: 'hydration' })

expect(hydrationErrors).toEqual([])
})
}
})

// Default: null → test "slate"
test.describe('background theme: slate', () => {
for (const page of PAGES) {
test(`${page}`, async ({ page: pw, goto, hydrationErrors }) => {
await injectLocalStorage(pw, {
'npmx-settings': JSON.stringify({ preferredBackgroundTheme: 'slate' }),
})
await goto(page, { waitUntil: 'hydration' })

expect(hydrationErrors).toEqual([])
})
}
})

// Default: "npm" → test "pnpm"
test.describe('package manager: pnpm', () => {
for (const page of PAGES) {
test(`${page}`, async ({ page: pw, goto, hydrationErrors }) => {
await injectLocalStorage(pw, {
'npmx-pm': JSON.stringify('pnpm'),
})
await goto(page, { waitUntil: 'hydration' })

expect(hydrationErrors).toEqual([])
})
}
})

// Default: "en-US" (LTR) → test "ar-EG" (RTL)
test.describe('locale: ar-EG (RTL)', () => {
for (const page of PAGES) {
test(`${page}`, async ({ page: pw, goto, hydrationErrors }) => {
await injectLocalStorage(pw, {
'npmx-settings': JSON.stringify({ selectedLocale: 'ar-EG' }),
})
await goto(page, { waitUntil: 'hydration' })

expect(hydrationErrors).toEqual([])
})
}
})

// Default: false → test true
test.describe('relative dates: enabled', () => {
for (const page of PAGES) {
test(`${page}`, async ({ page: pw, goto, hydrationErrors }) => {
await injectLocalStorage(pw, {
'npmx-settings': JSON.stringify({ relativeDates: true }),
})
await goto(page, { waitUntil: 'hydration' })

expect(hydrationErrors).toEqual([])
})
}
})
})

async function injectLocalStorage(page: Page, entries: Record<string, string>) {
await page.addInitScript((e: Record<string, string>) => {
for (const [key, value] of Object.entries(e)) {
localStorage.setItem(key, value)
}
}, entries)
}
50 changes: 45 additions & 5 deletions test/e2e/test-utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Page, Route } from '@playwright/test'
import { test as base } from '@nuxt/test-utils/playwright'
import type { ConsoleMessage, Page, Route } from '@playwright/test'
import { test as base, expect } from '@nuxt/test-utils/playwright'
import { createRequire } from 'node:module'

const require = createRequire(import.meta.url)
Expand Down Expand Up @@ -50,19 +50,59 @@ async function setupRouteMocking(page: Page): Promise<void> {
}

/**
* Extended test fixture with automatic external API mocking.
* Patterns that indicate a Vue hydration mismatch in console output.
*
* Vue always emits `console.error("Hydration completed but contains mismatches.")`
* in production builds when a hydration mismatch occurs.
*
* When `debug.hydration: true` is enabled (sets `__VUE_PROD_HYDRATION_MISMATCH_DETAILS__`),
* Vue also emits more detailed warnings (text content mismatch, node mismatch, etc.).
* We catch both the summary error and the detailed warnings.
*/
const HYDRATION_MISMATCH_PATTERNS = [
'Hydration completed but contains mismatches',
'Hydration text content mismatch',
'Hydration node mismatch',
'Hydration children mismatch',
'Hydration attribute mismatch',
'Hydration class mismatch',
'Hydration style mismatch',
]

function isHydrationMismatch(message: ConsoleMessage): boolean {
const text = message.text()
return HYDRATION_MISMATCH_PATTERNS.some(pattern => text.includes(pattern))
}

/**
* Extended test fixture with automatic external API mocking and hydration mismatch detection.
*
* All external API requests are intercepted and served from fixtures.
* If a request cannot be mocked, the test will fail with a clear error.
*
* Hydration mismatches are detected via Vue's console.error output, which is always
* emitted in production builds when server-rendered HTML doesn't match client expectations.
*/
export const test = base.extend<{ mockExternalApis: void }>({
export const test = base.extend<{ mockExternalApis: void; hydrationErrors: string[] }>({
mockExternalApis: [
async ({ page }, use) => {
await setupRouteMocking(page)
await use()
},
{ auto: true },
],

hydrationErrors: async ({ page }, use) => {
const errors: string[] = []

page.on('console', message => {
if (isHydrationMismatch(message)) {
errors.push(message.text())
}
})

await use(errors)
},
})

export { expect } from '@nuxt/test-utils/playwright'
export { expect }
2 changes: 1 addition & 1 deletion test/unit/a11y-component-coverage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const SKIPPED_COMPONENTS: Record<string, string> = {
'Modal.client.vue':
'Base modal component - tested via specific modals like ChartModal, ConnectorModal',
'Package/SkillsModal.vue': 'Complex modal with tabs - requires modal context and state',
'ScrollToTop.vue': 'Requires scroll position and CSS scroll-state queries',
'ScrollToTop.client.vue': 'Requires scroll position and CSS scroll-state queries',
'Settings/TranslationHelper.vue': 'i18n helper component - requires specific locale status data',
'Package/WeeklyDownloadStats.vue':
'Uses vue-data-ui VueUiSparkline - has DOM measurement issues in test environment',
Expand Down
Loading