[READ-ONLY] a fast, modern browser for the npm registry

test: add hydration tests (#1248)

authored by

Daniel Roe and committed by
GitHub
9afb54fd 82528008

+176 -6
app/components/ScrollToTop.vue app/components/ScrollToTop.client.vue
+6
nuxt.config.ts
··· 17 17 '@nuxtjs/color-mode', 18 18 ], 19 19 20 + $test: { 21 + debug: { 22 + hydration: true, 23 + }, 24 + }, 25 + 20 26 colorMode: { 21 27 preference: 'system', 22 28 fallback: 'dark',
+124
test/e2e/hydration.spec.ts
··· 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 + }
+45 -5
test/e2e/test-utils.ts
··· 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' 3 3 import { createRequire } from 'node:module' 4 4 5 5 const require = createRequire(import.meta.url) ··· 50 50 } 51 51 52 52 /** 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. 54 79 * 55 80 * All external API requests are intercepted and served from fixtures. 56 81 * 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. 57 85 */ 58 - export const test = base.extend<{ mockExternalApis: void }>({ 86 + export const test = base.extend<{ mockExternalApis: void; hydrationErrors: string[] }>({ 59 87 mockExternalApis: [ 60 88 async ({ page }, use) => { 61 89 await setupRouteMocking(page) ··· 63 91 }, 64 92 { auto: true }, 65 93 ], 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 + }, 66 106 }) 67 107 68 - export { expect } from '@nuxt/test-utils/playwright' 108 + export { expect }
+1 -1
test/unit/a11y-component-coverage.spec.ts
··· 36 36 'Modal.client.vue': 37 37 'Base modal component - tested via specific modals like ChartModal, ConnectorModal', 38 38 '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', 40 40 'Settings/TranslationHelper.vue': 'i18n helper component - requires specific locale status data', 41 41 'Package/WeeklyDownloadStats.vue': 42 42 'Uses vue-data-ui VueUiSparkline - has DOM measurement issues in test environment',