Ver Fonte

chore: 'origin/main' into minor

daiwei há 10 horas atrás
pai
commit
b196013746

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

@@ -52,12 +52,19 @@ 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-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
+
       - name: Setup Vite+
         uses: voidzero-dev/setup-vp@v1
         with:
           node-version-file: '.node-version'
           cache: true
       - run: node node_modules/puppeteer/install.mjs
+      - run: vp exec playwright install chromium
 
       - name: Run e2e tests
         run: vp run test-e2e

+ 2 - 0
changelogs/CHANGELOG-3.5.md

@@ -1,3 +1,5 @@
+## [3.5.38](https://github.com/vuejs/core/compare/v3.5.37...v3.5.38) (2026-06-11)
+
 ## [3.5.37](https://github.com/vuejs/core/compare/v3.5.36...v3.5.37) (2026-06-11)
 
 ## [3.5.36](https://github.com/vuejs/core/compare/v3.5.35...v3.5.36) (2026-06-11)

+ 1 - 1
package.json

@@ -18,7 +18,7 @@
     "format-check": "vp fmt --check",
     "test": "vp test",
     "test-unit": "vp test --project unit*",
-    "test-e2e": "node scripts/build.js -e vue -f global+esm-browser-vapor -d && vp test --project e2e",
+    "test-e2e": "node scripts/build.js -e vue -f global+esm-browser-vapor -d && vp test --project e2e --project e2e-browser",
     "test-e2e-vapor": "vp run prepare-e2e-vapor && VAPOR_E2E=1 vp test --project e2e-vapor",
     "prepare-e2e-vapor": "node scripts/build.js -e -f cjs+esm-bundler+esm-bundler-runtime && vp build packages-private/vapor-e2e-test",
     "test-dts": "run-s build-dts test-dts-only",

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

@@ -1,17 +1,23 @@
-// @vitest-environment jsdom
-
-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)
 
@@ -24,7 +30,7 @@ describe('e2e: Transition', () => {
     })
 
   beforeEach(async () => {
-    await page().goto(baseUrl)
+    await reset()
     await page().waitForSelector('#app')
   })
 
@@ -972,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-->')
@@ -996,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>')
@@ -2324,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
@@ -2538,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(
@@ -2608,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>',
@@ -3417,6 +3422,7 @@ describe('e2e: Transition', () => {
     test(
       'warn invalid durations',
       async () => {
+        const { createApp } = (window as any).Vue
         createApp({
           template: `
             <div id="container">
@@ -3502,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, {
@@ -3515,6 +3522,7 @@ describe('e2e: Transition', () => {
   })
 
   test('warn when invalid transition mode', () => {
+    const { createApp } = (window as any).Vue
     createApp({
       template: `
         <div id="container">
@@ -3531,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 - 12
packages/vue/__tests__/e2e/TransitionGroup.spec.ts

@@ -1,15 +1,10 @@
-// @vitest-environment jsdom
-
-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(() => {
@@ -22,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')
   })
 
@@ -680,6 +675,7 @@ describe('e2e: TransitionGroup', () => {
   )
 
   test('warn unkeyed children', () => {
+    const { createApp, ref } = (window as any).Vue
     createApp({
       template: `
         <transition-group name="test">
@@ -696,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">
@@ -790,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 - 4
packages/vue/__tests__/e2e/transition.html

@@ -1,4 +0,0 @@
-<script src="../../dist/vue.global.js"></script>
-
-<div id="app"></div>
-<link rel="stylesheet" href="style.css" />

+ 29 - 0
vite.config.ts

@@ -118,6 +118,35 @@ 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({
+              launchOptions: {
+                args: process.env.CI
+                  ? ['--no-sandbox', '--disable-setuid-sandbox']
+                  : [],
+              },
+            }),
+            headless: true,
+            instances: [{ browser: 'chromium' }],
+          },
         },
       },
       // @ts-expect-error - https://github.com/vuejs/core/actions/runs/23430103557/job/68154030981