test: migrate hmr test to use playwright runner (#31241)

This commit is contained in:
Daniel Roe 2025-03-07 01:19:48 +00:00 committed by GitHub
parent b27294e205
commit e78a456c43
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 503 additions and 548 deletions

View File

@ -302,7 +302,7 @@ jobs:
path: packages
- name: Test (fixtures)
run: pnpm test:fixtures
run: pnpm test:fixtures && pnpm test:e2e
env:
TEST_ENV: ${{ matrix.env }}
TEST_BUILDER: ${{ matrix.builder }}

2
.gitignore vendored
View File

@ -78,3 +78,5 @@ fixtures-temp
.pnpm-store
eslint-typegen.d.ts
.eslintcache
test-results/
playwright-report

View File

@ -33,6 +33,11 @@
"test:types": "pnpm --filter './test/fixtures/**' test:types",
"test:unit": "vitest run packages/",
"test:attw": "pnpm --filter './packages/**' test:attw",
"test:e2e": "playwright test",
"test:e2e:debug": "playwright test --debug",
"test:e2e:ui": "playwright test --ui",
"test:e2e:dev": "TEST_ENV=dev playwright test",
"test:e2e:webpack": "TEST_BUILDER=webpack playwright test",
"typecheck": "tsc --noEmit",
"typecheck:docs": "DOCS_TYPECHECK=true pnpm nuxi prepare && nuxt-content-twoslash verify --content-dir docs --languages html"
},
@ -77,6 +82,7 @@
"@nuxt/rspack-builder": "workspace:*",
"@nuxt/test-utils": "3.17.1",
"@nuxt/webpack-builder": "workspace:*",
"@playwright/test": "1.51.0",
"@testing-library/vue": "8.1.0",
"@types/babel__core": "7.20.5",
"@types/babel__helper-plugin-utils": "7.10.3",
@ -91,6 +97,7 @@
"changelogen": "0.6.1",
"consola": "3.4.0",
"cssnano": "7.0.6",
"defu": "^6.1.4",
"destr": "2.0.3",
"devalue": "5.1.1",
"eslint": "9.21.0",
@ -112,7 +119,7 @@
"ofetch": "1.4.1",
"pathe": "2.0.3",
"pkg-pr-new": "0.0.40",
"playwright-core": "1.50.1",
"playwright-core": "1.51.0",
"rollup": "4.34.9",
"semver": "7.7.1",
"sherif": "1.4.0",

34
playwright.config.ts Normal file
View File

@ -0,0 +1,34 @@
import { defineConfig, devices } from '@playwright/test'
import type { ConfigOptions } from '@nuxt/test-utils/playwright'
import { isCI, isWindows } from 'std-env'
/**
* Playwright configuration for Nuxt e2e tests
* @see https://playwright.dev/docs/test-configuration
*/
export default defineConfig<ConfigOptions>({
testDir: './test/e2e',
testMatch: '**/*.test.ts',
timeout: (isWindows ? 360 : 120) * 1000,
fullyParallel: true,
forbidOnly: !!isCI,
retries: isCI ? 2 : 0,
workers: isCI ? 1 : undefined,
reporter: 'html',
projects: [
{
name: 'setup fixtures',
testMatch: /global\.setup\.ts/,
teardown: 'cleanup fixtures',
},
{
name: 'cleanup fixtures',
testMatch: /global\.teardown\.ts/,
},
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
dependencies: ['setup fixtures'],
},
],
})

File diff suppressed because it is too large Load Diff

View File

@ -58,7 +58,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
const serverDir = join(rootDir, '.output/server')
const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir)
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"205k"`)
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"204k"`)
const modules = await analyzeSizes(['node_modules/**/*'], serverDir)
expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot(`"1397k"`)
@ -97,7 +97,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
const serverDir = join(rootDir, '.output-inline/server')
const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir)
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"556k"`)
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"555k"`)
const modules = await analyzeSizes(['node_modules/**/*'], serverDir)
expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot(`"90.9k"`)

19
test/e2e/global.setup.ts Normal file
View File

@ -0,0 +1,19 @@
import { existsSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { cp, rm } from 'node:fs/promises'
import { test as setup } from '@playwright/test'
const fixtureDir = fileURLToPath(new URL('../fixtures-temp/hmr', import.meta.url))
const sourceDir = fileURLToPath(new URL('../fixtures/hmr', import.meta.url))
setup('create temporary hmr fixture directory', async () => {
if (existsSync(fixtureDir)) {
await rm(fixtureDir, { force: true, recursive: true })
}
await cp(sourceDir, fixtureDir, {
recursive: true,
filter: (src) => {
return !src.includes('.cache') && !src.endsWith('.sock') && !src.includes('.output') && !src.includes('.nuxt-')
},
})
})

View File

@ -0,0 +1,12 @@
import { existsSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { rm } from 'node:fs/promises'
import { test as teardown } from '@playwright/test'
const fixtureDir = fileURLToPath(new URL('../fixtures-temp/hmr', import.meta.url))
teardown('remove temporary hmr fixture directory', async () => {
if (existsSync(fixtureDir)) {
await rm(fixtureDir, { force: true, recursive: true })
}
})

177
test/e2e/hmr.test.ts Normal file
View File

@ -0,0 +1,177 @@
import { readFileSync, writeFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { rm } from 'node:fs/promises'
import { isWindows } from 'std-env'
import { join } from 'pathe'
import { expect, test } from './test-utils'
const isWebpack = process.env.TEST_BUILDER === 'webpack' || process.env.TEST_BUILDER === 'rspack'
const fixtureDir = fileURLToPath(new URL('../fixtures-temp/hmr', import.meta.url))
const sourceDir = fileURLToPath(new URL('../fixtures/hmr', import.meta.url))
test.use({
nuxt: {
rootDir: fixtureDir,
dev: true,
setupTimeout: (isWindows ? 360 : 120) * 1000,
env: { TEST: '1' },
nuxtConfig: {
test: true,
builder: isWebpack ? 'webpack' : 'vite',
},
},
})
if (process.env.TEST_ENV === 'built' || isWindows) {
test.skip('Skipped: HMR tests are skipped on Windows or in built mode', () => {})
} else {
test.describe.configure({ mode: 'serial' })
// Load the fixture file
const indexVue = readFileSync(join(sourceDir, 'pages/index.vue'), 'utf8')
test('basic HMR functionality', async ({ page, goto }) => {
// Navigate to the page
writeFileSync(join(fixtureDir, 'pages/index.vue'), indexVue)
await goto('/')
// Check initial state
await expect(page).toHaveTitle('HMR fixture')
await expect(page.locator('[data-testid="count"]')).toHaveText('1')
// Test reactivity
await page.locator('button').click()
await expect(page.locator('[data-testid="count"]')).toHaveText('2')
// Modify the file and check for HMR updates
let newContents = indexVue
.replace('<Title>HMR fixture</Title>', '<Title>HMR fixture HMR</Title>')
.replace('<h1>Home page</h1>', '<h1>Home page - but not as you knew it</h1>')
newContents += '<style scoped>\nh1 { color: red }\n</style>'
writeFileSync(join(fixtureDir, 'pages/index.vue'), newContents)
// Wait for the title to be updated via HMR
await expect(page).toHaveTitle('HMR fixture HMR')
// Check content HMR
const h1 = page.locator('h1')
await expect(h1).toHaveText('Home page - but not as you knew it')
// Check style HMR
const h1Color = await h1.evaluate(el => window.getComputedStyle(el).getPropertyValue('color'))
expect(h1Color).toBe('rgb(255, 0, 0)')
expect(page).toHaveNoErrorsOrWarnings()
})
test('detecting new routes', async ({ fetch }) => {
// Try accessing a non-existent route
await rm(join(fixtureDir, 'pages/some-404.vue'), { force: true })
const res = await fetch('/some-404')
expect(res.status).toBe(404)
// Create a new page file
writeFileSync(join(fixtureDir, 'pages/some-404.vue'), indexVue)
// Wait for the new route to be available
await expect(() => fetch('/some-404').then(r => r.status).catch(() => false)).toBeWithPolling(200)
})
test('hot reloading route rules', async ({ fetch }) => {
// Check the initial header
const file = readFileSync(join(sourceDir, 'pages/route-rules.vue'), 'utf8')
writeFileSync(join(fixtureDir, 'pages/route-rules.vue'), file)
await expect(() => fetch('/route-rules').then(r => r.headers.get('x-extend')).catch(() => null)).toBeWithPolling('added in routeRules')
await new Promise(resolve => setTimeout(resolve, 100))
// Modify the route rules
writeFileSync(join(fixtureDir, 'pages/route-rules.vue'), file.replace('added in routeRules', 'edited in dev'))
// Wait for the route rule to be hot reloaded
await expect(() => fetch('/route-rules').then(r => r.headers.get('x-extend')).catch(() => null)).toBeWithPolling('edited in dev')
})
test('HMR for island components', async ({ page, goto }) => {
// Navigate to the page with the island components
await goto('/server-component')
const componentPath = join(fixtureDir, 'components/islands/HmrComponent.vue')
const componentContents = readFileSync(componentPath, 'utf8')
// Test initial state of the component
await expect(page.getByTestId('hmr-id')).toHaveText('0')
// Function to update the component and check for changes
const triggerHmr = (number: string) => writeFileSync(componentPath, componentContents.replace('ref(0)', `ref(${number})`))
// First edit
triggerHmr('1')
await expect(page.getByTestId('hmr-id')).toHaveText('1', { timeout: 10000 })
// Second edit to make sure HMR is working consistently
triggerHmr('2')
await expect(page.getByTestId('hmr-id')).toHaveText('2', { timeout: 10000 })
expect(page).toHaveNoErrorsOrWarnings()
})
// Skip if using webpack since this test only works with Vite
if (!isWebpack) {
test('HMR for page meta', async ({ page, goto }) => {
const pageContents = readFileSync(join(sourceDir, 'pages/page-meta.vue'), 'utf8')
writeFileSync(join(fixtureDir, 'pages/page-meta.vue'), pageContents)
await goto('/page-meta')
// Check initial meta state
await expect(page.getByTestId('meta')).toHaveText(JSON.stringify({ some: 'stuff' }, null, 2))
// Update the meta
writeFileSync(join(fixtureDir, 'pages/page-meta.vue'), pageContents.replace(`some: 'stuff'`, `some: 'other stuff'`))
// Check if meta updates
await expect(page.getByTestId('meta')).toHaveText(JSON.stringify({ some: 'other stuff' }, null, 2))
// Verify no errors in console
expect(page).toHaveNoErrorsOrWarnings()
})
test('HMR for routes', async ({ page, goto }) => {
await goto('/routes')
// Create a new route that doesn't exist yet
writeFileSync(
join(fixtureDir, 'pages/routes/non-existent.vue'),
`<template><div data-testid="contents">A new route!</div></template>`,
)
// Track console logs
const consoleLogs: Array<{ type: string, text: string }> = []
page.on('console', (msg) => {
consoleLogs.push({
type: msg.type(),
text: msg.text(),
})
})
// Wait for HMR to process the new route
await expect(() => consoleLogs.some(log => log.text.includes('hmr'))).toBeWithPolling(true)
// Navigate to the new route
await page.locator('a[href="/routes/non-existent"]').click()
// Verify the new route content is rendered
await expect(page.getByTestId('contents')).toHaveText('A new route!')
// Filter expected warnings about route not existing before the update
const filteredLogs = consoleLogs.filter(log => (log.type === 'warning' || log.type === 'error') && !log.text.includes('No match found for location with path "/routes/non-existent"'))
// Verify no unexpected errors
expect(filteredLogs).toStrictEqual([])
})
}
}

117
test/e2e/test-utils.ts Normal file
View File

@ -0,0 +1,117 @@
import { waitForHydration } from '@nuxt/test-utils/e2e'
import { test as base, expect as baseExpect } from '@nuxt/test-utils/playwright'
import type { Page } from '@playwright/test'
import { fetch } from 'ofetch'
import { joinURL } from 'ufo'
const test = base.extend<{ fetch: (path: string) => Promise<Response> }>({
fetch: ({ request, _nuxtHooks }, use) => {
use(async (path) => {
let res: Response | undefined
do {
res = await fetch(joinURL(_nuxtHooks.ctx.url!, path), {
headers: { 'accept': 'text/html' },
signal: AbortSignal.timeout(1000),
}).catch(() => undefined)
} while (!res || res?.status === 503 || res?.status === 500)
if (!res) {
await request.get(path, { headers: { 'accept': 'text/html' } })
}
return res
})
},
})
test.use({
page: ({ page }, use) => {
const consoleLogs: Array<{ type: string, text: string }> = []
page.on('console', (msg) => {
consoleLogs.push({
type: msg.type(),
text: msg.text(),
})
})
// @ts-expect-error untyped
page._consoleLogs = consoleLogs
return use(page)
},
goto: ({ page }, use) => {
use(async (path, options) => {
const result = await page.goto(path, options as any)
await waitForHydration(page, path, 'hydration')
return result
})
},
})
const expect = baseExpect.extend({
// Utility function to wait for a condition to be true
async toBeWithPolling <T = true> (
getter: () => Promise<T> | T,
expected: T | ((val: T) => boolean) = true as T,
options: { timeout?: number, interval?: number, message?: string } = {},
) {
const { timeout = 8000, interval = 300 } = options
const startTime = Date.now()
let lastValue: T | undefined
let lastError: Error | undefined
// Create a matcher function
const matcher = typeof expected === 'function'
? expected as ((val: T) => boolean)
: (val: T) => val === expected
let pass = false
while (Date.now() - startTime < timeout) {
try {
lastValue = await getter()
if (matcher(lastValue)) {
pass = true
break
}
} catch (err) {
lastError = err as Error
}
// Wait before next attempt
await new Promise(resolve => setTimeout(resolve, interval))
}
const message = options.message || `Timed out after ${timeout}ms waiting for condition to be met.`
// if (lastError) {
// throw new Error(`${errorMessage}\nLast error: ${lastError.message}`)
// }
// throw new Error(`${errorMessage}\nExpected: ${expected}\nReceived: ${lastValue!}`)
return {
message: () => pass ? '' : lastError ? `${message}\nLast error: ${lastError.message}` : `${message}\nExpected: ${expected}\nReceived: ${lastValue!}`,
pass,
name: 'toBeWithPolling',
expected,
actual: lastValue,
}
},
toHaveNoErrorsOrWarnings (page: Page) {
// @ts-expect-error untyped
const consoleLogs: Array<{ text: string, type: string }> = page._consoleLogs
const errorLogs = consoleLogs.filter(log =>
log.type === 'error' || (log.type === 'warning' && !log.text.includes('webpack/hot/dev-server')))
const pass = errorLogs.length === 0
const message = pass ? '' : `Found error logs: ${errorLogs.map(log => log.text).join('\n')}`
return {
message: () => message,
pass,
name: 'toHaveNoErrorsOrWarnings',
expected: [],
actual: errorLogs,
}
},
})
export { test, expect }

View File

@ -1,176 +0,0 @@
import { promises as fsp } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { beforeAll, describe, expect, it } from 'vitest'
import { isWindows } from 'std-env'
import { join } from 'pathe'
import { $fetch as _$fetch, fetch, setup } from '@nuxt/test-utils/e2e'
import { expectNoErrorsOrWarnings, expectWithPolling, renderPage } from './utils'
// TODO: update @nuxt/test-utils
const $fetch = _$fetch as import('nitro/types').$Fetch<unknown, import('nitro/types').NitroFetchRequest>
const isWebpack = process.env.TEST_BUILDER === 'webpack' || process.env.TEST_BUILDER === 'rspack'
// TODO: fix HMR on Windows
if (process.env.TEST_ENV !== 'built' && !isWindows) {
const fixturePath = fileURLToPath(new URL('./fixtures-temp/hmr', import.meta.url))
await setup({
rootDir: fixturePath,
dev: true,
server: true,
browser: true,
setupTimeout: (isWindows ? 360 : 120) * 1000,
nuxtConfig: {
buildDir: join(fixturePath, '.nuxt', 'test', Math.random().toString(36).slice(2, 8)),
builder: isWebpack ? 'webpack' : 'vite',
},
})
const indexVue = await fsp.readFile(join(fixturePath, 'pages/index.vue'), 'utf8')
describe('hmr', { sequential: true }, () => {
beforeAll(async () => {
await expectWithPolling(() => $fetch<string>('/').then(r => r.includes('Home page')).catch(() => null), true)
})
it('should work', async () => {
const { page, pageErrors, consoleLogs } = await renderPage('/')
expect(await page.title()).toBe('HMR fixture')
expect(await page.getByTestId('count').textContent()).toBe('1')
// reactive
await page.getByRole('button').click()
expect(await page.getByTestId('count').textContent()).toBe('2')
// modify file
let newContents = indexVue
.replace('<Title>HMR fixture</Title>', '<Title>HMR fixture HMR</Title>')
.replace('<h1>Home page</h1>', '<h1>Home page - but not as you knew it</h1>')
newContents += '<style scoped>\nh1 { color: red }\n</style>'
await fsp.writeFile(join(fixturePath, 'pages/index.vue'), newContents)
await expectWithPolling(() => page.title(), 'HMR fixture HMR')
// content HMR
const h1 = page.getByRole('heading')
expect(await h1!.textContent()).toBe('Home page - but not as you knew it')
// style HMR
const h1Color = await h1.evaluate(el => window.getComputedStyle(el).getPropertyValue('color'))
expect(h1Color).toMatchInlineSnapshot('"rgb(255, 0, 0)"')
// ensure no errors
expectNoErrorsOrWarnings(consoleLogs)
expect(pageErrors).toEqual([])
await page.close()
})
it('should detect new routes', { timeout: 60000 }, async () => {
const res = await fetch('/some-404')
expect(res.status).toBe(404)
// write new page route
await fsp.writeFile(join(fixturePath, 'pages/some-404.vue'), indexVue)
await expectWithPolling(() => $fetch<string>('/some-404').then(r => r.includes('Home page')).catch(() => null), true)
})
it('should hot reload route rules', { timeout: 60000 }, async () => {
await expectWithPolling(() => fetch('/route-rules').then(r => r.headers.get('x-extend')).catch(() => null), 'added in routeRules')
// write new page route
const file = await fsp.readFile(join(fixturePath, 'pages/route-rules.vue'), 'utf8')
await fsp.writeFile(join(fixturePath, 'pages/route-rules.vue'), file.replace('added in routeRules', 'edited in dev'))
await expectWithPolling(() => fetch('/route-rules').then(r => r.headers.get('x-extend')).catch(() => null), 'edited in dev')
})
it('should HMR islands', async () => {
const { page, pageErrors, consoleLogs } = await renderPage('/server-component')
const componentPath = join(fixturePath, 'components/islands/HmrComponent.vue')
const componentContents = await fsp.readFile(componentPath, 'utf8')
const triggerHmr = (number: string) => fsp.writeFile(componentPath, componentContents.replace('ref(0)', `ref(${number})`))
// initial state
await expectWithPolling(async () => await page.getByTestId('hmr-id').innerText(), '0')
// first edit
await triggerHmr('1')
await expectWithPolling(async () => await page.getByTestId('hmr-id').innerText(), '1')
// just in-case
await triggerHmr('2')
await expectWithPolling(async () => await page.getByTestId('hmr-id').innerText(), '2')
// ensure no errors
expectNoErrorsOrWarnings(consoleLogs)
expect(pageErrors).toEqual([])
await page.close()
})
it.skipIf(isWebpack)('should HMR page meta', async () => {
const { page, pageErrors, consoleLogs } = await renderPage('/page-meta')
const pagePath = join(fixturePath, 'pages/page-meta.vue')
const pageContents = await fsp.readFile(pagePath, 'utf8')
expect(JSON.parse(await page.getByTestId('meta').textContent() || '{}')).toStrictEqual({ some: 'stuff' })
const initialConsoleLogs = structuredClone(consoleLogs)
await fsp.writeFile(pagePath, pageContents.replace(`some: 'stuff'`, `some: 'other stuff'`))
await expectWithPolling(async () => await page.getByTestId('meta').textContent() || '{}', JSON.stringify({ some: 'other stuff' }, null, 2))
expect(consoleLogs).toStrictEqual([
...initialConsoleLogs,
{
'text': '[vite] hot updated: /pages/page-meta.vue',
'type': 'debug',
},
{
'text': '[vite] hot updated: /pages/page-meta.vue?macro=true',
'type': 'debug',
},
{
'text': `[vite] hot updated: /@id/virtual:nuxt:${encodeURIComponent(join(fixturePath, '.nuxt/routes.mjs'))}`,
'type': 'debug',
},
])
// ensure no errors
expectNoErrorsOrWarnings(consoleLogs)
expect(pageErrors).toEqual([])
await page.close()
})
it.skipIf(isWebpack)('should HMR routes', { timeout: 60_000 }, async () => {
const { page, pageErrors, consoleLogs } = await renderPage('/routes', { retries: 5 })
await fsp.writeFile(join(fixturePath, 'pages/routes/non-existent.vue'), `<template><div data-testid="contents">A new route!</div></template>`)
await expectWithPolling(() => consoleLogs.some(log => log.text.includes('hmr')), true)
await page.getByRole('link').click()
await expectWithPolling(() => page.getByTestId('contents').textContent(), 'A new route!')
for (const log of consoleLogs) {
if (log.text.includes('No match found for location with path "/routes/non-existent"')) {
// we expect this warning before the routes are updated
log.type = 'debug'
}
}
// ensure no errors
expectNoErrorsOrWarnings(consoleLogs)
expect(pageErrors).toEqual([])
await page.close()
})
})
} else {
describe.skip('hmr', () => {})
}

View File

@ -1,25 +0,0 @@
import { existsSync } from 'node:fs'
import { cp, rm } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'pathe'
const dir = dirname(fileURLToPath(import.meta.url))
const fixtureDir = join(dir, 'fixtures')
const tempDir = join(dir, 'fixtures-temp')
export async function setup () {
if (existsSync(tempDir)) {
await rm(tempDir, { force: true, recursive: true })
}
await cp(fixtureDir, tempDir, {
recursive: true,
filter: src => !src.includes('.cache'),
})
}
export async function teardown () {
if (existsSync(tempDir)) {
await rm(tempDir, { force: true, recursive: true })
}
}

View File

@ -15,14 +15,13 @@ export default defineConfig({
},
},
test: {
globalSetup: './test/setup.ts',
setupFiles: ['./test/setup-env.ts'],
coverage: {
exclude: [...coverageConfigDefaults.exclude, 'packages/nuxt/src/app', 'playground', '**/test/', 'scripts', 'vitest.nuxt.config.ts'],
},
testTimeout: isWindows ? 60000 : 10000,
// Excluded plugin because it should throw an error when accidentally loaded via Nuxt
exclude: [...configDefaults.exclude, 'nuxt/**', '**/test.ts', '**/this-should-not-load.spec.js'],
exclude: [...configDefaults.exclude, 'test/e2e/**', 'e2e/**', 'nuxt/**', '**/test.ts', '**/this-should-not-load.spec.js'],
poolOptions: {
threads: {
maxThreads: process.env.TEST_ENV === 'dev' ? 1 : undefined,