diff --git a/app/components/ScrollToTop.vue b/app/components/ScrollToTop.client.vue similarity index 100% rename from app/components/ScrollToTop.vue rename to app/components/ScrollToTop.client.vue diff --git a/nuxt.config.ts b/nuxt.config.ts index 49ce0c9dc..c31cd6fd2 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -17,6 +17,12 @@ export default defineNuxtConfig({ '@nuxtjs/color-mode', ], + $test: { + debug: { + hydration: true, + }, + }, + colorMode: { preference: 'system', fallback: 'dark', diff --git a/test/e2e/hydration.spec.ts b/test/e2e/hydration.spec.ts new file mode 100644 index 000000000..0f1c6ae9e --- /dev/null +++ b/test/e2e/hydration.spec.ts @@ -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) { + await page.addInitScript((e: Record) => { + for (const [key, value] of Object.entries(e)) { + localStorage.setItem(key, value) + } + }, entries) +} diff --git a/test/e2e/test-utils.ts b/test/e2e/test-utils.ts index 3901cba8c..66465b03c 100644 --- a/test/e2e/test-utils.ts +++ b/test/e2e/test-utils.ts @@ -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) @@ -50,12 +50,40 @@ async function setupRouteMocking(page: Page): Promise { } /** - * 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) @@ -63,6 +91,18 @@ export const test = base.extend<{ mockExternalApis: void }>({ }, { 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 } diff --git a/test/unit/a11y-component-coverage.spec.ts b/test/unit/a11y-component-coverage.spec.ts index 0cc0e40e5..4795b936b 100644 --- a/test/unit/a11y-component-coverage.spec.ts +++ b/test/unit/a11y-component-coverage.spec.ts @@ -36,7 +36,7 @@ const SKIPPED_COMPONENTS: Record = { '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',