|
|
@@ -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,
|
|
|
+ }
|
|
|
+}
|