Kaynağa Gözat

test(e2e): stabilize transition tests with vitest browser mode (#14970)

edison 18 saat önce
ebeveyn
işleme
325eb1d9a1

+ 7 - 0
.github/workflows/test.yml

@@ -62,6 +62,12 @@ jobs:
           path: ~/.cache/puppeteer
           key: chromium-${{ hashFiles('pnpm-lock.yaml') }}
 
+      - name: Setup cache for Playwright browsers
+        uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+        with:
+          path: ~/.cache/ms-playwright
+          key: playwright-${{ hashFiles('pnpm-lock.yaml') }}
+
       - name: Install pnpm
         uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
 
@@ -73,6 +79,7 @@ jobs:
 
       - run: pnpm install
       - run: node node_modules/puppeteer/install.mjs
+      - run: pnpm exec playwright install chromium
 
       - name: Run e2e tests
         run: pnpm run test-e2e

+ 3 - 1
package.json

@@ -18,7 +18,7 @@
     "format-check": "prettier --check --cache .",
     "test": "vitest",
     "test-unit": "vitest --project unit*",
-    "test-e2e": "node scripts/build.js vue -f global -d && vitest --project e2e",
+    "test-e2e": "node scripts/build.js vue -f global -d && vitest --project e2e --project e2e-browser",
     "test-dts": "run-s build-dts test-dts-only",
     "test-dts-only": "tsc -p packages-private/dts-built-test/tsconfig.json && tsc -p ./packages-private/dts-test/tsconfig.test.json",
     "test-coverage": "vitest run --project unit* --coverage",
@@ -74,6 +74,7 @@
     "@types/node": "^24.13.1",
     "@types/semver": "^7.7.1",
     "@types/serve-handler": "^6.1.4",
+    "@vitest/browser-playwright": "4.1.8",
     "@vitest/coverage-v8": "^4.1.8",
     "@vitest/eslint-plugin": "^1.6.19",
     "@vue/consolidate": "1.0.0",
@@ -93,6 +94,7 @@
     "marked": "13.0.3",
     "npm-run-all2": "^9.0.1",
     "picocolors": "^1.1.1",
+    "playwright": "^1.61.0",
     "prettier": "^3.8.4",
     "pretty-bytes": "^7.1.0",
     "pug": "^3.0.4",

+ 43 - 32
packages/vue/__tests__/e2e/Transition.spec.ts

@@ -1,15 +1,23 @@
-import type { ElementHandle } from 'puppeteer'
-import { E2E_TIMEOUT, setupPuppeteer } from './e2eUtils'
-import path from 'node:path'
-import { Transition, createApp, h, nextTick, ref } from 'vue'
+import type { ElementHandle } from './e2eBrowserUtils'
+import { E2E_TIMEOUT, setupBrowserE2E } from './e2eBrowserUtils'
 
 describe('e2e: Transition', () => {
-  const { page, html, classList, style, isVisible, timeout, nextFrame, click } =
-    setupPuppeteer()
-  const baseUrl = `file://${path.resolve(__dirname, './transition.html')}`
-
-  const duration = process.env.CI ? 200 : 50
-  const buffer = process.env.CI ? 50 : 20
+  const {
+    page,
+    reset,
+    html,
+    classList,
+    style,
+    isVisible,
+    timeout,
+    nextFrame,
+    click,
+  } = setupBrowserE2E()
+
+  const duration = 50
+  const buffer = 20
+
+  const nextTick = () => (window as any).Vue.nextTick()
 
   const transitionFinish = (time = duration) => timeout(time + buffer)
 
@@ -22,7 +30,7 @@ describe('e2e: Transition', () => {
     })
 
   beforeEach(async () => {
-    await page().goto(baseUrl)
+    await reset()
     await page().waitForSelector('#app')
   })
 
@@ -970,15 +978,13 @@ describe('e2e: Transition', () => {
           'test-anim-long-leave-to',
         ])
 
-        if (!process.env.CI) {
-          await new Promise(r => {
-            setTimeout(r, duration - buffer)
-          })
-          expect(await classList('#container div')).toStrictEqual([
-            'test-anim-long-leave-active',
-            'test-anim-long-leave-to',
-          ])
-        }
+        await new Promise(r => {
+          setTimeout(r, duration - buffer)
+        })
+        expect(await classList('#container div')).toStrictEqual([
+          'test-anim-long-leave-active',
+          'test-anim-long-leave-to',
+        ])
 
         await transitionFinish(duration * 2)
         expect(await html('#container')).toBe('<!--v-if-->')
@@ -994,15 +1000,13 @@ describe('e2e: Transition', () => {
           'test-anim-long-enter-to',
         ])
 
-        if (!process.env.CI) {
-          await new Promise(r => {
-            setTimeout(r, duration - buffer)
-          })
-          expect(await classList('#container div')).toStrictEqual([
-            'test-anim-long-enter-active',
-            'test-anim-long-enter-to',
-          ])
-        }
+        await new Promise(r => {
+          setTimeout(r, duration - buffer)
+        })
+        expect(await classList('#container div')).toStrictEqual([
+          'test-anim-long-enter-active',
+          'test-anim-long-enter-to',
+        ])
 
         await transitionFinish(duration * 2)
         expect(await html('#container')).toBe('<div class="">content</div>')
@@ -2322,6 +2326,9 @@ describe('e2e: Transition', () => {
         await click('#toggleBtn')
         await nextFrame()
         expect(await html('#container')).toBe('<div class="">Loading...</div>')
+        // The warning is from the initial `view = null` branch, where the
+        // dynamic component renders as an empty Suspense default slot.
+        expect('<Suspense> slots expect a single root node.').toHaveBeenWarned()
 
         await page().evaluate(() => {
           // @ts-expect-error
@@ -2536,7 +2543,7 @@ describe('e2e: Transition', () => {
         expect(await html('#container')).toBe('<div class="test">one</div>')
 
         // trigger twice
-        classWhenTransitionStart()
+        await classWhenTransitionStart()
         classWhenTransitionStart()
         await nextFrame()
         expect(await html('#container')).toBe(
@@ -2606,7 +2613,7 @@ describe('e2e: Transition', () => {
         )
 
         // trigger twice
-        classWhenTransitionStart()
+        await classWhenTransitionStart()
         await nextFrame()
         expect(await html('#container')).toBe(
           '<div>Top</div><div class="test test-leave-active test-leave-to">one</div><div>Bottom</div>',
@@ -3415,6 +3422,7 @@ describe('e2e: Transition', () => {
     test(
       'warn invalid durations',
       async () => {
+        const { createApp } = (window as any).Vue
         createApp({
           template: `
             <div id="container">
@@ -3500,6 +3508,7 @@ describe('e2e: Transition', () => {
   })
 
   test('warn when used on multiple elements', async () => {
+    const { Transition, createApp, h } = (window as any).Vue
     createApp({
       render() {
         return h(Transition, null, {
@@ -3513,6 +3522,7 @@ describe('e2e: Transition', () => {
   })
 
   test('warn when invalid transition mode', () => {
+    const { createApp } = (window as any).Vue
     createApp({
       template: `
         <div id="container">
@@ -3529,13 +3539,14 @@ describe('e2e: Transition', () => {
   test(`HOC w/ merged hooks`, async () => {
     const innerSpy = vi.fn()
     const outerSpy = vi.fn()
+    const { Transition, createApp, h, nextTick, ref } = (window as any).Vue
 
     const MyTransition = {
       render(this: any) {
         return h(
           Transition,
           {
-            onLeave(el, end) {
+            onLeave(el: Element, end: () => void) {
               innerSpy()
               end()
             },

+ 9 - 10
packages/vue/__tests__/e2e/TransitionGroup.spec.ts

@@ -1,13 +1,10 @@
-import { E2E_TIMEOUT, setupPuppeteer } from './e2eUtils'
-import path from 'node:path'
-import { createApp, ref } from 'vue'
+import { E2E_TIMEOUT, setupBrowserE2E } from './e2eBrowserUtils'
 
 describe('e2e: TransitionGroup', () => {
-  const { page, html, nextFrame, timeout } = setupPuppeteer()
-  const baseUrl = `file://${path.resolve(__dirname, './transition.html')}`
+  const { page, reset, html, nextFrame, timeout } = setupBrowserE2E()
 
-  const duration = process.env.CI ? 200 : 50
-  const buffer = process.env.CI ? 20 : 5
+  const duration = 50
+  const buffer = 20
 
   const htmlWhenTransitionStart = () =>
     page().evaluate(() => {
@@ -20,7 +17,7 @@ describe('e2e: TransitionGroup', () => {
   const transitionFinish = (time = duration) => timeout(time + buffer)
 
   beforeEach(async () => {
-    await page().goto(baseUrl)
+    await reset()
     await page().waitForSelector('#app')
   })
 
@@ -678,6 +675,7 @@ describe('e2e: TransitionGroup', () => {
   )
 
   test('warn unkeyed children', () => {
+    const { createApp, ref } = (window as any).Vue
     createApp({
       template: `
         <transition-group name="test">
@@ -694,6 +692,7 @@ describe('e2e: TransitionGroup', () => {
   })
 
   test('not warn unkeyed text children w/ whitespace preserve', () => {
+    const { createApp } = (window as any).Vue
     const app = createApp({
       template: `
         <transition-group name="test">
@@ -788,8 +787,8 @@ describe('e2e: TransitionGroup', () => {
           template: `
             <div id="container">
               <transition-group name="test">
-                <div class="test">foo</div>
-                <div class="test" v-if="show">bar</div>
+                <div key="1" class="test">foo</div>
+                <div key="2" class="test" v-if="show">bar</div>
               </transition-group>
             </div>
             <button id="toggleBtn" @click="click">button</button>

+ 442 - 0
packages/vue/__tests__/e2e/e2eBrowserUtils.ts

@@ -0,0 +1,442 @@
+import { cdp } from 'vitest/browser'
+
+export const E2E_TIMEOUT: number = 30 * 1000
+
+const maxTries = 30
+const vueGlobalBuildUrl = new URL('../../dist/vue.global.js', import.meta.url)
+  .href
+const transitionStyle = `
+  .test {
+    -webkit-transition: opacity 50ms ease;
+    transition: opacity 50ms ease;
+  }
+  .group-move {
+    -webkit-transition: -webkit-transform 50ms ease;
+    transition: transform 50ms ease;
+  }
+  .v-appear,
+  .v-enter,
+  .v-leave-active,
+  .test-appear,
+  .test-enter,
+  .test-leave-active,
+  .test-reflow-enter,
+  .test-reflow-leave-to,
+  .hello,
+  .bye.active,
+  .changed-enter {
+    opacity: 0;
+  }
+  .test-reflow-leave-active,
+  .test-reflow-enter-active {
+    -webkit-transition: opacity 50ms ease;
+    transition: opacity 50ms ease;
+  }
+  .test-reflow-leave-from {
+    opacity: 0.9;
+  }
+  .test-anim-enter-active {
+    animation: test-enter 50ms;
+    -webkit-animation: test-enter 50ms;
+  }
+  .test-anim-leave-active {
+    animation: test-leave 50ms;
+    -webkit-animation: test-leave 50ms;
+  }
+  .test-anim-long-enter-active {
+    animation: test-enter 100ms;
+    -webkit-animation: test-enter 100ms;
+  }
+  .test-anim-long-leave-active {
+    animation: test-leave 100ms;
+    -webkit-animation: test-leave 100ms;
+  }
+  @keyframes test-enter {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+  @-webkit-keyframes test-enter {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+  @keyframes test-leave {
+    from {
+      opacity: 1;
+    }
+    to {
+      opacity: 0;
+    }
+  }
+  @-webkit-keyframes test-leave {
+    from {
+      opacity: 1;
+    }
+    to {
+      opacity: 0;
+    }
+  }
+`
+
+export const timeout = (n: number): Promise<void> =>
+  new Promise(resolve => setTimeout(resolve, n))
+
+export async function expectByPolling(
+  poll: () => Promise<any>,
+  expected: string,
+): Promise<void> {
+  for (let tries = 0; tries < maxTries; tries++) {
+    const actual = (await poll()) || ''
+    if (actual.indexOf(expected) > -1 || tries === maxTries - 1) {
+      expect(actual).toMatch(expected)
+      break
+    } else {
+      await timeout(50)
+    }
+  }
+}
+
+export interface ElementHandle<T extends Element = Element> {
+  evaluate<R>(fn: (node: T) => R | Promise<R>): Promise<R>
+}
+
+interface BrowserPage {
+  goto(url?: string): Promise<void>
+  waitForSelector(selector: string): Promise<Element>
+  evaluate<R>(fn: () => R | Promise<R>): Promise<R>
+  evaluate<Arg, R>(fn: (arg: Arg) => R | Promise<R>, arg: Arg): Promise<R>
+  exposeFunction(name: string, fn: (...args: any[]) => any): Promise<void>
+  $eval<R>(selector: string, fn: (node: Element) => R | Promise<R>): Promise<R>
+  $$eval<R>(
+    selector: string,
+    fn: (nodes: Element[]) => R | Promise<R>,
+  ): Promise<R>
+  createCDPSession(): Promise<{
+    send(method: string, params?: Record<string, unknown>): Promise<unknown>
+  }>
+  on(event: 'pageerror', handler: (...args: any[]) => void): void
+  off(event: 'pageerror', handler: (...args: any[]) => void): void
+}
+
+interface BrowserUtils {
+  page: () => BrowserPage
+  reset(): Promise<void>
+  click(selector: string): Promise<void>
+  count(selector: string): Promise<number>
+  text(selector: string): Promise<string | null>
+  value(selector: string): Promise<string>
+  html(selector: string): Promise<string>
+  classList(selector: string): Promise<string[]>
+  style(selector: string, property: keyof CSSStyleDeclaration): Promise<any>
+  children(selector: string): Promise<any[]>
+  isVisible(selector: string): Promise<boolean>
+  isChecked(selector: string): Promise<boolean>
+  isFocused(selector: string): Promise<boolean>
+  setValue(selector: string, value: string): Promise<void>
+  typeValue(selector: string, value: string): Promise<void>
+  enterValue(selector: string, value: string): Promise<void>
+  clearValue(selector: string): Promise<void>
+  timeout(time: number): Promise<void>
+  nextFrame(): Promise<void>
+}
+
+type PageErrorHandler = {
+  error: EventListener
+  rejection: EventListener
+}
+
+function installVueGlobalBuild() {
+  return new Promise<void>((resolve, reject) => {
+    const script = document.createElement('script')
+    script.async = false
+    script.src = vueGlobalBuildUrl
+    script.onload = () => {
+      script.remove()
+      if ((window as any).Vue) {
+        resolve()
+      } else {
+        reject(new Error('Failed to expose Vue from vue.global.js.'))
+      }
+    }
+    script.onerror = () => {
+      script.remove()
+      reject(new Error(`Failed to load ${vueGlobalBuildUrl}.`))
+    }
+    document.head.appendChild(script)
+  })
+}
+
+function installTransitionStyle() {
+  const style = document.createElement('style')
+  style.dataset.vueTransitionE2e = ''
+  style.textContent = transitionStyle
+  document.head.appendChild(style)
+}
+
+const vueGlobalBuildReady = installVueGlobalBuild()
+installTransitionStyle()
+
+export function setupBrowserE2E(): BrowserUtils {
+  const pageErrorHandlers = new Map<
+    (...args: any[]) => void,
+    PageErrorHandler
+  >()
+  const initialHeadNodes = new Set<Node>(Array.from(document.head.childNodes))
+
+  function resetPageErrorHandlers() {
+    pageErrorHandlers.forEach(({ error, rejection }) => {
+      window.removeEventListener('error', error)
+      window.removeEventListener('unhandledrejection', rejection)
+    })
+    pageErrorHandlers.clear()
+  }
+
+  function resetHead() {
+    Array.from(document.head.childNodes).forEach(node => {
+      if (
+        !initialHeadNodes.has(node) &&
+        !(
+          node instanceof HTMLStyleElement &&
+          node.dataset.vueTransitionE2e != null
+        )
+      ) {
+        node.remove()
+      }
+    })
+  }
+
+  async function resetPage() {
+    // Browser mode runs in Vitest's iframe instead of loading transition.html.
+    // Keep these specs on the same global build that `test-e2e` prepares.
+    resetPageErrorHandlers()
+    await vueGlobalBuildReady
+    resetHead()
+    localStorage.clear()
+    sessionStorage.clear()
+    document.body.innerHTML = '<div id="app"></div>'
+  }
+
+  function getElement<T extends Element = Element>(selector: string): T {
+    const el = document.querySelector<T>(selector)
+    if (!el) {
+      throw new Error(`Unable to find element: ${selector}`)
+    }
+    return el
+  }
+
+  function createElementHandle<T extends Element>(node: T): ElementHandle<T> {
+    return {
+      async evaluate<R>(fn: (node: T) => R | Promise<R>) {
+        return (await fn(node)) as Awaited<R>
+      },
+    }
+  }
+
+  function toExposedArg(arg: unknown) {
+    return arg instanceof Element ? createElementHandle(arg) : arg
+  }
+
+  const browserPage: BrowserPage = {
+    async goto() {
+      await resetPage()
+    },
+
+    async waitForSelector(selector) {
+      const existing = document.querySelector(selector)
+      if (existing) {
+        return existing
+      }
+
+      return await new Promise<Element>((resolve, reject) => {
+        const observer = new MutationObserver(() => {
+          const el = document.querySelector(selector)
+          if (el) {
+            cleanup()
+            resolve(el)
+          }
+        })
+        const timer = setTimeout(() => {
+          cleanup()
+          reject(new Error(`Timed out waiting for selector: ${selector}`))
+        }, 1000)
+        const cleanup = () => {
+          clearTimeout(timer)
+          observer.disconnect()
+        }
+
+        observer.observe(document.documentElement, {
+          childList: true,
+          subtree: true,
+        })
+      })
+    },
+
+    async evaluate(fn: (...args: any[]) => any, arg?: unknown) {
+      const result = await fn(arg)
+      // Match the async boundary Puppeteer's page.evaluate used to provide.
+      await Promise.resolve() // Vue patch job queued by the evaluated callback.
+      await Promise.resolve() // Suspense async setup / branch resolution.
+      await Promise.resolve() // DOM transition start queued after resolution.
+      return result
+    },
+
+    async exposeFunction(name, fn) {
+      ;(window as any)[name] = (...args: unknown[]) =>
+        fn(...args.map(toExposedArg))
+    },
+
+    async $eval(selector, fn) {
+      return (await fn(getElement(selector))) as Awaited<ReturnType<typeof fn>>
+    },
+
+    async $$eval(selector, fn) {
+      return (await fn(
+        Array.from(document.querySelectorAll(selector)),
+      )) as Awaited<ReturnType<typeof fn>>
+    },
+
+    async createCDPSession() {
+      const session = cdp() as {
+        send(method: string, params?: Record<string, unknown>): Promise<unknown>
+      }
+      return {
+        send: (method, params) => session.send(method, params),
+      }
+    },
+
+    on(event, handler) {
+      if (event !== 'pageerror') {
+        return
+      }
+      const error = ((e: ErrorEvent) => handler(e.error || e.message)) as
+        | EventListener
+        | any
+      const rejection = ((e: PromiseRejectionEvent) => handler(e.reason)) as
+        | EventListener
+        | any
+      pageErrorHandlers.set(handler, { error, rejection })
+      window.addEventListener('error', error)
+      window.addEventListener('unhandledrejection', rejection)
+    },
+
+    off(event, handler) {
+      if (event !== 'pageerror') {
+        return
+      }
+      const listeners = pageErrorHandlers.get(handler)
+      if (listeners) {
+        window.removeEventListener('error', listeners.error)
+        window.removeEventListener('unhandledrejection', listeners.rejection)
+        pageErrorHandlers.delete(handler)
+      }
+    },
+  }
+
+  async function click(selector: string) {
+    getElement<HTMLElement>(selector).click()
+  }
+
+  async function reset() {
+    await resetPage()
+  }
+
+  async function count(selector: string) {
+    return document.querySelectorAll(selector).length
+  }
+
+  async function text(selector: string) {
+    return getElement(selector).textContent
+  }
+
+  async function value(selector: string) {
+    return getElement<HTMLInputElement>(selector).value
+  }
+
+  async function html(selector: string) {
+    return getElement(selector).innerHTML
+  }
+
+  async function classList(selector: string) {
+    return Array.from(getElement(selector).classList)
+  }
+
+  async function children(selector: string) {
+    return Array.from(getElement(selector).children)
+  }
+
+  async function style(selector: string, property: keyof CSSStyleDeclaration) {
+    return window.getComputedStyle(getElement(selector))[property]
+  }
+
+  async function isVisible(selector: string) {
+    return window.getComputedStyle(getElement(selector)).display !== 'none'
+  }
+
+  async function isChecked(selector: string) {
+    return getElement<HTMLInputElement>(selector).checked
+  }
+
+  async function isFocused(selector: string) {
+    return getElement(selector) === document.activeElement
+  }
+
+  async function setValue(selector: string, value: string) {
+    const el = getElement<HTMLInputElement>(selector)
+    el.value = value
+    el.dispatchEvent(new Event('input'))
+  }
+
+  async function typeValue(selector: string, value: string) {
+    const el = getElement<HTMLInputElement>(selector)
+    el.value = value
+    el.dispatchEvent(new Event('input'))
+  }
+
+  async function enterValue(selector: string, value: string) {
+    await typeValue(selector, value)
+    getElement<HTMLInputElement>(selector).dispatchEvent(
+      new KeyboardEvent('keydown', { key: 'Enter' }),
+    )
+  }
+
+  async function clearValue(selector: string) {
+    getElement<HTMLInputElement>(selector).value = ''
+  }
+
+  async function nextFrame() {
+    return new Promise<void>(resolve => {
+      requestAnimationFrame(() => {
+        requestAnimationFrame(() => resolve())
+      })
+    })
+  }
+
+  return {
+    page: () => browserPage,
+    reset,
+    click,
+    count,
+    text,
+    value,
+    html,
+    classList,
+    style,
+    children,
+    isVisible,
+    isChecked,
+    isFocused,
+    setValue,
+    typeValue,
+    enterValue,
+    clearValue,
+    timeout,
+    nextFrame,
+  }
+}

+ 0 - 82
packages/vue/__tests__/e2e/transition.html

@@ -1,82 +0,0 @@
-<script src="../../dist/vue.global.js"></script>
-
-<div id="app"></div>
-<style>
-  .test {
-    -webkit-transition: opacity 50ms ease;
-    transition: opacity 50ms ease;
-  }
-  .group-move {
-    -webkit-transition: -webkit-transform 50ms ease;
-    transition: transform 50ms ease;
-  }
-  .v-appear,
-  .v-enter,
-  .v-leave-active,
-  .test-appear,
-  .test-enter,
-  .test-leave-active,
-  .test-reflow-enter,
-  .test-reflow-leave-to,
-  .hello,
-  .bye.active,
-  .changed-enter {
-    opacity: 0;
-  }
-  .test-reflow-leave-active,
-  .test-reflow-enter-active {
-    -webkit-transition: opacity 50ms ease;
-    transition: opacity 50ms ease;
-  }
-  .test-reflow-leave-from {
-    opacity: 0.9;
-  }
-  .test-anim-enter-active {
-    animation: test-enter 50ms;
-    -webkit-animation: test-enter 50ms;
-  }
-  .test-anim-leave-active {
-    animation: test-leave 50ms;
-    -webkit-animation: test-leave 50ms;
-  }
-  .test-anim-long-enter-active {
-    animation: test-enter 100ms;
-    -webkit-animation: test-enter 100ms;
-  }
-  .test-anim-long-leave-active {
-    animation: test-leave 100ms;
-    -webkit-animation: test-leave 100ms;
-  }
-  @keyframes test-enter {
-    from {
-      opacity: 0;
-    }
-    to {
-      opacity: 1;
-    }
-  }
-  @-webkit-keyframes test-enter {
-    from {
-      opacity: 0;
-    }
-    to {
-      opacity: 1;
-    }
-  }
-  @keyframes test-leave {
-    from {
-      opacity: 1;
-    }
-    to {
-      opacity: 0;
-    }
-  }
-  @-webkit-keyframes test-leave {
-    from {
-      opacity: 1;
-    }
-    to {
-      opacity: 0;
-    }
-  }
-</style>

+ 121 - 7
pnpm-lock.yaml

@@ -72,9 +72,12 @@ importers:
       '@types/serve-handler':
         specifier: ^6.1.4
         version: 6.1.4
+      '@vitest/browser-playwright':
+        specifier: 4.1.8
+        version: 4.1.8(playwright@1.61.0)(vite@8.0.16)(vitest@4.1.8)
       '@vitest/coverage-v8':
         specifier: ^4.1.8
-        version: 4.1.8(vitest@4.1.8)
+        version: 4.1.8(@vitest/browser@4.1.8)(vitest@4.1.8)
       '@vitest/eslint-plugin':
         specifier: ^1.6.19
         version: 1.6.19(@typescript-eslint/eslint-plugin@8.61.0)(eslint@10.4.1)(typescript@5.6.3)(vitest@4.1.8)
@@ -129,6 +132,9 @@ importers:
       picocolors:
         specifier: ^1.1.1
         version: 1.1.1
+      playwright:
+        specifier: ^1.61.0
+        version: 1.61.0
       prettier:
         specifier: ^3.8.4
         version: 3.8.4
@@ -185,7 +191,7 @@ importers:
         version: 8.0.16(@types/node@24.13.1)(esbuild@0.28.0)(sass@1.100.0)(yaml@2.9.0)
       vitest:
         specifier: ^4.1.8
-        version: 4.1.8(@types/node@24.13.1)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16)
+        version: 4.1.8(@types/node@24.13.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16)
 
   packages-private/dts-built-test:
     dependencies:
@@ -507,6 +513,9 @@ packages:
     resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
     engines: {node: '>=18'}
 
+  '@blazediff/core@1.9.1':
+    resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==}
+
   '@bramus/specificity@2.4.2':
     resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
     hasBin: true
@@ -898,6 +907,9 @@ packages:
     resolution: {integrity: sha512-HNjmfLQEVRZmHRET336f20H/8kOozUGwk7yajvsonjNxbj2wBTK1WsQuHkD5yYh9RxFGL2EyDHryOihOwUoKDA==}
     engines: {node: '>= 10.0.0'}
 
+  '@polka/url@1.0.0-next.29':
+    resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
+
   '@puppeteer/browsers@3.0.4':
     resolution: {integrity: sha512-HGM8iAmGTf+Y7t0373szVbTmt3d7vPkYL/1bpOkOFO0YUYLgSeuYBCzESklogNPvOBnZ/MRD5f07OkpqH1trtA==}
     engines: {node: '>=22.12.0'}
@@ -1570,6 +1582,17 @@ packages:
       vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
       vue: ^3.2.25
 
+  '@vitest/browser-playwright@4.1.8':
+    resolution: {integrity: sha512-SR7FqgegaexEg73xvf3ArtygXegagMdXnL0EZMpxrWvvhQxvicD/E8p0ib0J91riPRtQUViyh67Xjw3NqvyhVg==}
+    peerDependencies:
+      playwright: '*'
+      vitest: 4.1.8
+
+  '@vitest/browser@4.1.8':
+    resolution: {integrity: sha512-u21VzX07HzlJYpFgkxmjEXar/tG2UqWGgyGG/46SrrPc7rSdCTPw5vuowopO9CIqF8UCUQzDFdbVnNpw6N0BfQ==}
+    peerDependencies:
+      vitest: 4.1.8
+
   '@vitest/coverage-v8@4.1.8':
     resolution: {integrity: sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==}
     peerDependencies:
@@ -2148,6 +2171,11 @@ packages:
   flatted@3.3.1:
     resolution: {integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==}
 
+  fsevents@2.3.2:
+    resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
+    engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+    os: [darwin]
+
   fsevents@2.3.3:
     resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
     engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -2620,6 +2648,10 @@ packages:
   monaco-editor@0.55.1:
     resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==}
 
+  mrmime@2.0.1:
+    resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
+    engines: {node: '>=10'}
+
   ms@2.0.0:
     resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
 
@@ -2746,6 +2778,20 @@ packages:
     engines: {node: '>=0.10'}
     hasBin: true
 
+  playwright-core@1.61.0:
+    resolution: {integrity: sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==}
+    engines: {node: '>=18'}
+    hasBin: true
+
+  playwright@1.61.0:
+    resolution: {integrity: sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==}
+    engines: {node: '>=18'}
+    hasBin: true
+
+  pngjs@7.0.0:
+    resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==}
+    engines: {node: '>=14.19.0'}
+
   postcss-modules-extract-imports@3.1.0:
     resolution: {integrity: sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==}
     engines: {node: ^10 || ^12 || >= 14}
@@ -3010,6 +3056,10 @@ packages:
     resolution: {integrity: sha512-WszCLXwT4h2k1ufIXAgsbiTOazqqevFCIncOuUBZJ91DdvWcC5+OFkluWRQPrcuSYd8fjq+o2y1QfWqYMoAToQ==}
     hasBin: true
 
+  sirv@3.0.2:
+    resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==}
+    engines: {node: '>=18'}
+
   slice-ansi@7.1.0:
     resolution: {integrity: sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==}
     engines: {node: '>=18'}
@@ -3146,6 +3196,10 @@ packages:
   token-stream@1.0.0:
     resolution: {integrity: sha512-VSsyNPPW74RpHwR8Fc21uubwHY7wMDeJLys2IX5zJNih+OnAnaifKHo+1LHT7DAdloQ7apeaaWg8l7qnf/TnEg==}
 
+  totalist@3.0.1:
+    resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
+    engines: {node: '>=6'}
+
   tough-cookie@6.0.1:
     resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==}
     engines: {node: '>=16'}
@@ -3481,6 +3535,8 @@ snapshots:
 
   '@bcoe/v8-coverage@1.0.2': {}
 
+  '@blazediff/core@1.9.1': {}
+
   '@bramus/specificity@2.4.2':
     dependencies:
       css-tree: 3.2.1
@@ -3753,6 +3809,8 @@ snapshots:
       '@parcel/watcher-win32-x64': 2.4.1
     optional: true
 
+  '@polka/url@1.0.0-next.29': {}
+
   '@puppeteer/browsers@3.0.4':
     dependencies:
       modern-tar: 0.7.6
@@ -4259,7 +4317,37 @@ snapshots:
       vite: 8.0.16(@types/node@24.13.1)(esbuild@0.28.0)(sass@1.100.0)(yaml@2.9.0)
       vue: link:packages/vue
 
-  '@vitest/coverage-v8@4.1.8(vitest@4.1.8)':
+  '@vitest/browser-playwright@4.1.8(playwright@1.61.0)(vite@8.0.16)(vitest@4.1.8)':
+    dependencies:
+      '@vitest/browser': 4.1.8(vite@8.0.16)(vitest@4.1.8)
+      '@vitest/mocker': 4.1.8(vite@8.0.16)
+      playwright: 1.61.0
+      tinyrainbow: 3.1.0
+      vitest: 4.1.8(@types/node@24.13.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16)
+    transitivePeerDependencies:
+      - bufferutil
+      - msw
+      - utf-8-validate
+      - vite
+
+  '@vitest/browser@4.1.8(vite@8.0.16)(vitest@4.1.8)':
+    dependencies:
+      '@blazediff/core': 1.9.1
+      '@vitest/mocker': 4.1.8(vite@8.0.16)
+      '@vitest/utils': 4.1.8
+      magic-string: 0.30.21
+      pngjs: 7.0.0
+      sirv: 3.0.2
+      tinyrainbow: 3.1.0
+      vitest: 4.1.8(@types/node@24.13.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16)
+      ws: 8.21.0
+    transitivePeerDependencies:
+      - bufferutil
+      - msw
+      - utf-8-validate
+      - vite
+
+  '@vitest/coverage-v8@4.1.8(@vitest/browser@4.1.8)(vitest@4.1.8)':
     dependencies:
       '@bcoe/v8-coverage': 1.0.2
       '@vitest/utils': 4.1.8
@@ -4271,7 +4359,9 @@ snapshots:
       obug: 2.1.1
       std-env: 4.0.0
       tinyrainbow: 3.1.0
-      vitest: 4.1.8(@types/node@24.13.1)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16)
+      vitest: 4.1.8(@types/node@24.13.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16)
+    optionalDependencies:
+      '@vitest/browser': 4.1.8(vite@8.0.16)(vitest@4.1.8)
 
   '@vitest/eslint-plugin@1.6.19(@typescript-eslint/eslint-plugin@8.61.0)(eslint@10.4.1)(typescript@5.6.3)(vitest@4.1.8)':
     dependencies:
@@ -4281,7 +4371,7 @@ snapshots:
     optionalDependencies:
       '@typescript-eslint/eslint-plugin': 8.61.0(@typescript-eslint/parser@8.61.0)(eslint@10.4.1)(typescript@5.6.3)
       typescript: 5.6.3
-      vitest: 4.1.8(@types/node@24.13.1)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16)
+      vitest: 4.1.8(@types/node@24.13.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16)
     transitivePeerDependencies:
       - supports-color
 
@@ -4861,6 +4951,9 @@ snapshots:
 
   flatted@3.3.1: {}
 
+  fsevents@2.3.2:
+    optional: true
+
   fsevents@2.3.3:
     optional: true
 
@@ -5278,6 +5371,8 @@ snapshots:
       dompurify: 3.2.7
       marked: 14.0.0
 
+  mrmime@2.0.1: {}
+
   ms@2.0.0: {}
 
   ms@2.1.3: {}
@@ -5382,6 +5477,16 @@ snapshots:
 
   pidtree@0.6.0: {}
 
+  playwright-core@1.61.0: {}
+
+  playwright@1.61.0:
+    dependencies:
+      playwright-core: 1.61.0
+    optionalDependencies:
+      fsevents: 2.3.2
+
+  pngjs@7.0.0: {}
+
   postcss-modules-extract-imports@3.1.0(postcss@8.5.15):
     dependencies:
       postcss: 8.5.15
@@ -5757,6 +5862,12 @@ snapshots:
 
   simple-git-hooks@2.13.1: {}
 
+  sirv@3.0.2:
+    dependencies:
+      '@polka/url': 1.0.0-next.29
+      mrmime: 2.0.1
+      totalist: 3.0.1
+
   slice-ansi@7.1.0:
     dependencies:
       ansi-styles: 6.2.3
@@ -5879,6 +5990,8 @@ snapshots:
 
   token-stream@1.0.0: {}
 
+  totalist@3.0.1: {}
+
   tough-cookie@6.0.1:
     dependencies:
       tldts: 7.0.16
@@ -5984,7 +6097,7 @@ snapshots:
       sass: 1.100.0
       yaml: 2.9.0
 
-  vitest@4.1.8(@types/node@24.13.1)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16):
+  vitest@4.1.8(@types/node@24.13.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16):
     dependencies:
       '@vitest/expect': 4.1.8
       '@vitest/mocker': 4.1.8(vite@8.0.16)
@@ -6008,7 +6121,8 @@ snapshots:
       why-is-node-running: 2.3.0
     optionalDependencies:
       '@types/node': 24.13.1
-      '@vitest/coverage-v8': 4.1.8(vitest@4.1.8)
+      '@vitest/browser-playwright': 4.1.8(playwright@1.61.0)(vite@8.0.16)(vitest@4.1.8)
+      '@vitest/coverage-v8': 4.1.8(@vitest/browser@4.1.8)(vitest@4.1.8)
       jsdom: 29.1.1
     transitivePeerDependencies:
       - msw

+ 24 - 0
vitest.config.ts

@@ -1,4 +1,5 @@
 import { configDefaults, defineConfig } from 'vitest/config'
+import { playwright } from '@vitest/browser-playwright'
 import { entries } from './scripts/aliases.js'
 
 export default defineConfig({
@@ -85,6 +86,29 @@ export default defineConfig({
           environment: 'jsdom',
           isolate: true,
           include: ['packages/vue/__tests__/e2e/*.spec.ts'],
+          exclude: [
+            'packages/vue/__tests__/e2e/Transition.spec.ts',
+            'packages/vue/__tests__/e2e/TransitionGroup.spec.ts',
+          ],
+        },
+      },
+      {
+        extends: true,
+        define: {
+          __BROWSER__: true,
+        },
+        test: {
+          name: 'e2e-browser',
+          include: [
+            'packages/vue/__tests__/e2e/Transition.spec.ts',
+            'packages/vue/__tests__/e2e/TransitionGroup.spec.ts',
+          ],
+          browser: {
+            enabled: true,
+            provider: playwright(),
+            headless: true,
+            instances: [{ browser: 'chromium' }],
+          },
         },
       },
     ],