From 9c45a2d1669981bccb8df6f2db723ea1f7e27ace Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 9 Feb 2026 00:27:18 +0000 Subject: [PATCH 1/6] test: add hydration test --- test/e2e/hydration.spec.ts | 11 +++++++++ test/e2e/test-utils.ts | 50 ++++++++++++++++++++++++++++++++++---- 2 files changed, 56 insertions(+), 5 deletions(-) create mode 100644 test/e2e/hydration.spec.ts diff --git a/test/e2e/hydration.spec.ts b/test/e2e/hydration.spec.ts new file mode 100644 index 000000000..49e501ced --- /dev/null +++ b/test/e2e/hydration.spec.ts @@ -0,0 +1,11 @@ +import { expect, test } from './test-utils' + +test.describe('Hydration', () => { + test('/ (homepage) has no hydration mismatches', async ({ goto, hydrationErrors }) => { + await goto('/', { waitUntil: 'hydration' }) + // await goto('/about', { waitUntil: 'hydration' }) + // await goto('/settings', { waitUntil: 'hydration' }) + + expect(hydrationErrors).toEqual([]) + }) +}) 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 } From 7527f231685e5819c3629d7372b58b08b4df4a19 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 9 Feb 2026 12:53:15 +0000 Subject: [PATCH 2/6] test: add failing test for `/about` page --- nuxt.config.ts | 6 ++++++ test/e2e/hydration.spec.ts | 8 ++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) 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 index 49e501ced..5a1ee41dd 100644 --- a/test/e2e/hydration.spec.ts +++ b/test/e2e/hydration.spec.ts @@ -3,8 +3,12 @@ import { expect, test } from './test-utils' test.describe('Hydration', () => { test('/ (homepage) has no hydration mismatches', async ({ goto, hydrationErrors }) => { await goto('/', { waitUntil: 'hydration' }) - // await goto('/about', { waitUntil: 'hydration' }) - // await goto('/settings', { waitUntil: 'hydration' }) + + expect(hydrationErrors).toEqual([]) + }) + + test('/about has no hydration mismatches', async ({ goto, hydrationErrors }) => { + await goto('/about', { waitUntil: 'hydration' }) expect(hydrationErrors).toEqual([]) }) From 9344ad9fbf717912b4f6f4c8d27d30e52b233044 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 9 Feb 2026 14:17:24 +0000 Subject: [PATCH 3/6] fix: work around upstream bug --- app/components/{ScrollToTop.vue => ScrollToTop.client.vue} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename app/components/{ScrollToTop.vue => ScrollToTop.client.vue} (100%) 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 From d2a6af00321e43980242d62c9d1b2b94943ced73 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 9 Feb 2026 14:45:38 +0000 Subject: [PATCH 4/6] test: add more tests for hydration mismatch --- test/e2e/hydration.spec.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/test/e2e/hydration.spec.ts b/test/e2e/hydration.spec.ts index 5a1ee41dd..888b3a73c 100644 --- a/test/e2e/hydration.spec.ts +++ b/test/e2e/hydration.spec.ts @@ -12,4 +12,28 @@ test.describe('Hydration', () => { expect(hydrationErrors).toEqual([]) }) + + test('/settings has no hydration mismatches', async ({ goto, hydrationErrors }) => { + await goto('/settings', { waitUntil: 'hydration' }) + + expect(hydrationErrors).toEqual([]) + }) + + test('/privacy has no hydration mismatches', async ({ goto, hydrationErrors }) => { + await goto('/privacy', { waitUntil: 'hydration' }) + + expect(hydrationErrors).toEqual([]) + }) + + test('/compare has no hydration mismatches', async ({ goto, hydrationErrors }) => { + await goto('/compare', { waitUntil: 'hydration' }) + + expect(hydrationErrors).toEqual([]) + }) + + test('/packages/nuxt has no hydration mismatches', async ({ goto, hydrationErrors }) => { + await goto('/packages/nuxt', { waitUntil: 'hydration' }) + + expect(hydrationErrors).toEqual([]) + }) }) From 03fbdf930ddde04f4c1d0afbfa02c848b5e462d1 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 9 Feb 2026 16:25:47 +0000 Subject: [PATCH 5/6] test: add hydration matrix --- test/e2e/hydration.spec.ts | 121 +++++++++++++++++++++++++++++++------ 1 file changed, 103 insertions(+), 18 deletions(-) diff --git a/test/e2e/hydration.spec.ts b/test/e2e/hydration.spec.ts index 888b3a73c..0f1c6ae9e 100644 --- a/test/e2e/hydration.spec.ts +++ b/test/e2e/hydration.spec.ts @@ -1,39 +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('/ (homepage) has no hydration mismatches', async ({ goto, hydrationErrors }) => { - await goto('/', { waitUntil: '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([]) + expect(hydrationErrors).toEqual([]) + }) + } }) - test('/about has no hydration mismatches', async ({ goto, hydrationErrors }) => { - await goto('/about', { waitUntil: 'hydration' }) + // 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([]) + expect(hydrationErrors).toEqual([]) + }) + } }) - test('/settings has no hydration mismatches', async ({ goto, hydrationErrors }) => { - await goto('/settings', { waitUntil: 'hydration' }) + // 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([]) + expect(hydrationErrors).toEqual([]) + }) + } }) - test('/privacy has no hydration mismatches', async ({ goto, hydrationErrors }) => { - await goto('/privacy', { waitUntil: 'hydration' }) + // 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([]) + expect(hydrationErrors).toEqual([]) + }) + } }) - test('/compare has no hydration mismatches', async ({ goto, hydrationErrors }) => { - await goto('/compare', { waitUntil: 'hydration' }) + // 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([]) + expect(hydrationErrors).toEqual([]) + }) + } }) - test('/packages/nuxt has no hydration mismatches', async ({ goto, hydrationErrors }) => { - await goto('/packages/nuxt', { waitUntil: 'hydration' }) + // 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([]) + 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) +} From 9a699bc92307a5b508451cebe4d47ebbe10c67a8 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 9 Feb 2026 16:41:00 +0000 Subject: [PATCH 6/6] test: update ScrollToTop.client.vue name --- test/unit/a11y-component-coverage.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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',