| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442 |
- 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,
- }
- }
|