Skip to content

Commit 9afb54f

Browse files
authored
test: add hydration tests (#1248)
1 parent 8252800 commit 9afb54f

File tree

5 files changed

+176
-6
lines changed

5 files changed

+176
-6
lines changed

nuxt.config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ export default defineNuxtConfig({
1717
'@nuxtjs/color-mode',
1818
],
1919

20+
$test: {
21+
debug: {
22+
hydration: true,
23+
},
24+
},
25+
2026
colorMode: {
2127
preference: 'system',
2228
fallback: 'dark',

test/e2e/hydration.spec.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import type { Page } from '@playwright/test'
2+
import { expect, test } from './test-utils'
3+
4+
const PAGES = [
5+
'/',
6+
'/about',
7+
'/settings',
8+
'/privacy',
9+
'/compare',
10+
'/search',
11+
'/package/nuxt',
12+
'/search?q=vue',
13+
] as const
14+
15+
// ---------------------------------------------------------------------------
16+
// Test matrix
17+
//
18+
// For each user setting, we test two states across all pages:
19+
// 1. undefined — empty localStorage, the default/fresh-install experience
20+
// 2. a non-default value — verifies hydration still works when the user has
21+
// changed that setting from its default
22+
// ---------------------------------------------------------------------------
23+
24+
test.describe('Hydration', () => {
25+
test.describe('no user settings (empty localStorage)', () => {
26+
for (const page of PAGES) {
27+
test(`${page}`, async ({ goto, hydrationErrors }) => {
28+
await goto(page, { waitUntil: 'hydration' })
29+
30+
expect(hydrationErrors).toEqual([])
31+
})
32+
}
33+
})
34+
35+
// Default: "system" → test explicit "dark"
36+
test.describe('color mode: dark', () => {
37+
for (const page of PAGES) {
38+
test(`${page}`, async ({ page: pw, goto, hydrationErrors }) => {
39+
await injectLocalStorage(pw, { 'npmx-color-mode': 'dark' })
40+
await goto(page, { waitUntil: 'hydration' })
41+
42+
expect(hydrationErrors).toEqual([])
43+
})
44+
}
45+
})
46+
47+
// Default: null → test "violet"
48+
test.describe('accent color: violet', () => {
49+
for (const page of PAGES) {
50+
test(`${page}`, async ({ page: pw, goto, hydrationErrors }) => {
51+
await injectLocalStorage(pw, {
52+
'npmx-settings': JSON.stringify({ accentColorId: 'violet' }),
53+
})
54+
await goto(page, { waitUntil: 'hydration' })
55+
56+
expect(hydrationErrors).toEqual([])
57+
})
58+
}
59+
})
60+
61+
// Default: null → test "slate"
62+
test.describe('background theme: slate', () => {
63+
for (const page of PAGES) {
64+
test(`${page}`, async ({ page: pw, goto, hydrationErrors }) => {
65+
await injectLocalStorage(pw, {
66+
'npmx-settings': JSON.stringify({ preferredBackgroundTheme: 'slate' }),
67+
})
68+
await goto(page, { waitUntil: 'hydration' })
69+
70+
expect(hydrationErrors).toEqual([])
71+
})
72+
}
73+
})
74+
75+
// Default: "npm" → test "pnpm"
76+
test.describe('package manager: pnpm', () => {
77+
for (const page of PAGES) {
78+
test(`${page}`, async ({ page: pw, goto, hydrationErrors }) => {
79+
await injectLocalStorage(pw, {
80+
'npmx-pm': JSON.stringify('pnpm'),
81+
})
82+
await goto(page, { waitUntil: 'hydration' })
83+
84+
expect(hydrationErrors).toEqual([])
85+
})
86+
}
87+
})
88+
89+
// Default: "en-US" (LTR) → test "ar-EG" (RTL)
90+
test.describe('locale: ar-EG (RTL)', () => {
91+
for (const page of PAGES) {
92+
test(`${page}`, async ({ page: pw, goto, hydrationErrors }) => {
93+
await injectLocalStorage(pw, {
94+
'npmx-settings': JSON.stringify({ selectedLocale: 'ar-EG' }),
95+
})
96+
await goto(page, { waitUntil: 'hydration' })
97+
98+
expect(hydrationErrors).toEqual([])
99+
})
100+
}
101+
})
102+
103+
// Default: false → test true
104+
test.describe('relative dates: enabled', () => {
105+
for (const page of PAGES) {
106+
test(`${page}`, async ({ page: pw, goto, hydrationErrors }) => {
107+
await injectLocalStorage(pw, {
108+
'npmx-settings': JSON.stringify({ relativeDates: true }),
109+
})
110+
await goto(page, { waitUntil: 'hydration' })
111+
112+
expect(hydrationErrors).toEqual([])
113+
})
114+
}
115+
})
116+
})
117+
118+
async function injectLocalStorage(page: Page, entries: Record<string, string>) {
119+
await page.addInitScript((e: Record<string, string>) => {
120+
for (const [key, value] of Object.entries(e)) {
121+
localStorage.setItem(key, value)
122+
}
123+
}, entries)
124+
}

test/e2e/test-utils.ts

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import type { Page, Route } from '@playwright/test'
2-
import { test as base } from '@nuxt/test-utils/playwright'
1+
import type { ConsoleMessage, Page, Route } from '@playwright/test'
2+
import { test as base, expect } from '@nuxt/test-utils/playwright'
33
import { createRequire } from 'node:module'
44

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

5252
/**
53-
* Extended test fixture with automatic external API mocking.
53+
* Patterns that indicate a Vue hydration mismatch in console output.
54+
*
55+
* Vue always emits `console.error("Hydration completed but contains mismatches.")`
56+
* in production builds when a hydration mismatch occurs.
57+
*
58+
* When `debug.hydration: true` is enabled (sets `__VUE_PROD_HYDRATION_MISMATCH_DETAILS__`),
59+
* Vue also emits more detailed warnings (text content mismatch, node mismatch, etc.).
60+
* We catch both the summary error and the detailed warnings.
61+
*/
62+
const HYDRATION_MISMATCH_PATTERNS = [
63+
'Hydration completed but contains mismatches',
64+
'Hydration text content mismatch',
65+
'Hydration node mismatch',
66+
'Hydration children mismatch',
67+
'Hydration attribute mismatch',
68+
'Hydration class mismatch',
69+
'Hydration style mismatch',
70+
]
71+
72+
function isHydrationMismatch(message: ConsoleMessage): boolean {
73+
const text = message.text()
74+
return HYDRATION_MISMATCH_PATTERNS.some(pattern => text.includes(pattern))
75+
}
76+
77+
/**
78+
* Extended test fixture with automatic external API mocking and hydration mismatch detection.
5479
*
5580
* All external API requests are intercepted and served from fixtures.
5681
* If a request cannot be mocked, the test will fail with a clear error.
82+
*
83+
* Hydration mismatches are detected via Vue's console.error output, which is always
84+
* emitted in production builds when server-rendered HTML doesn't match client expectations.
5785
*/
58-
export const test = base.extend<{ mockExternalApis: void }>({
86+
export const test = base.extend<{ mockExternalApis: void; hydrationErrors: string[] }>({
5987
mockExternalApis: [
6088
async ({ page }, use) => {
6189
await setupRouteMocking(page)
6290
await use()
6391
},
6492
{ auto: true },
6593
],
94+
95+
hydrationErrors: async ({ page }, use) => {
96+
const errors: string[] = []
97+
98+
page.on('console', message => {
99+
if (isHydrationMismatch(message)) {
100+
errors.push(message.text())
101+
}
102+
})
103+
104+
await use(errors)
105+
},
66106
})
67107

68-
export { expect } from '@nuxt/test-utils/playwright'
108+
export { expect }

test/unit/a11y-component-coverage.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ const SKIPPED_COMPONENTS: Record<string, string> = {
3636
'Modal.client.vue':
3737
'Base modal component - tested via specific modals like ChartModal, ConnectorModal',
3838
'Package/SkillsModal.vue': 'Complex modal with tabs - requires modal context and state',
39-
'ScrollToTop.vue': 'Requires scroll position and CSS scroll-state queries',
39+
'ScrollToTop.client.vue': 'Requires scroll position and CSS scroll-state queries',
4040
'Settings/TranslationHelper.vue': 'i18n helper component - requires specific locale status data',
4141
'Package/WeeklyDownloadStats.vue':
4242
'Uses vue-data-ui VueUiSparkline - has DOM measurement issues in test environment',

0 commit comments

Comments
 (0)