renderComponent.ts 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390
  1. // Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/
  2. import type { Plugin, Ref } from 'vue'
  3. import { isRef, nextTick, ref, watchEffect, unref } from 'vue'
  4. import type { Router, RouteRecordRaw } from 'vue-router'
  5. import { createRouter, createWebHistory } from 'vue-router'
  6. import type { MountingOptions } from '@vue/test-utils'
  7. import { mount } from '@vue/test-utils'
  8. import type { Matcher, RenderResult } from '@testing-library/vue'
  9. import { render } from '@testing-library/vue'
  10. import userEvent from '@testing-library/user-event'
  11. import { merge, cloneDeep } from 'lodash-es'
  12. import { plugin as formPlugin } from '@formkit/vue'
  13. import { buildFormKitPluginConfig } from '@shared/form'
  14. import applicationConfigPlugin from '@shared/plugins/applicationConfigPlugin'
  15. import CommonIcon from '@shared/components/CommonIcon/CommonIcon.vue'
  16. import CommonLink from '@shared/components/CommonLink/CommonLink.vue'
  17. import CommonDateTime from '@shared/components/CommonDateTime/CommonDateTime.vue'
  18. import CommonConfirmation from '@mobile/components/CommonConfirmation/CommonConfirmation.vue'
  19. import CommonImageViewer from '@shared/components/CommonImageViewer/CommonImageViewer.vue'
  20. import { imageViewerOptions } from '@shared/composables/useImageViewer'
  21. import DynamicInitializer from '@shared/components/DynamicInitializer/DynamicInitializer.vue'
  22. import { initializeWalker } from '@shared/router/walker'
  23. import { initializeObjectAttributes } from '@mobile/object-attributes/initializeObjectAttributes'
  24. import { i18n } from '@shared/i18n'
  25. import buildIconsQueries from './iconQueries'
  26. import buildLinksQueries from './linkQueries'
  27. import { waitForNextTick } from '../utils'
  28. import { cleanupStores, initializeStore } from './initializeStore'
  29. // TODO: some things can be handled differently: https://test-utils.vuejs.org/api/#config-global
  30. export interface ExtendedMountingOptions<Props> extends MountingOptions<Props> {
  31. router?: boolean
  32. routerRoutes?: RouteRecordRaw[]
  33. store?: boolean
  34. imageViewer?: boolean
  35. confirmation?: boolean
  36. form?: boolean
  37. formField?: boolean
  38. unmount?: boolean
  39. dialog?: boolean
  40. vModel?: {
  41. [prop: string]: unknown
  42. }
  43. }
  44. type UserEvent = ReturnType<(typeof userEvent)['setup']>
  45. interface PageEvents extends UserEvent {
  46. debounced(fn: () => unknown, ms?: number): Promise<void>
  47. }
  48. export interface ExtendedRenderResult extends RenderResult {
  49. events: PageEvents
  50. queryAllByIconName(matcher: Matcher): SVGElement[]
  51. queryByIconName(matcher: Matcher): SVGElement | null
  52. getAllByIconName(matcher: Matcher): SVGElement[]
  53. getByIconName(matcher: Matcher): SVGElement
  54. findAllByIconName(matcher: Matcher): Promise<SVGElement[]>
  55. findByIconName(matcher: Matcher): Promise<SVGElement>
  56. getLinkFromElement(element: Element): HTMLAnchorElement
  57. }
  58. const plugins: (Plugin | [Plugin, ...unknown[]])[] = [
  59. (app) => {
  60. app.config.globalProperties.i18n = i18n
  61. app.config.globalProperties.$t = i18n.t.bind(i18n)
  62. app.config.globalProperties.__ = (source: string) => source
  63. },
  64. ]
  65. const defaultWrapperOptions: ExtendedMountingOptions<unknown> = {
  66. global: {
  67. components: {
  68. CommonIcon,
  69. CommonLink,
  70. CommonDateTime,
  71. },
  72. stubs: {},
  73. plugins,
  74. },
  75. }
  76. interface MockedRouter extends Router {
  77. mockMethods(): void
  78. restoreMethods(): void
  79. }
  80. let routerInitialized = false
  81. let router: MockedRouter
  82. export const getTestRouter = () => router
  83. const initializeRouter = (routes?: RouteRecordRaw[]) => {
  84. if (routerInitialized) return
  85. let localRoutes: RouteRecordRaw[] = [
  86. {
  87. name: 'Dashboard',
  88. path: '/',
  89. component: {
  90. template: 'Welcome to zammad.',
  91. },
  92. },
  93. {
  94. name: 'Example',
  95. path: '/example',
  96. component: {
  97. template: 'This is a example page.',
  98. },
  99. },
  100. {
  101. name: 'Error',
  102. path: '/:pathMatch(.*)*',
  103. component: {
  104. template: 'Error page',
  105. },
  106. },
  107. ]
  108. // Use only the default routes, if nothing was given.
  109. if (routes) {
  110. localRoutes = routes
  111. }
  112. router = createRouter({
  113. history: createWebHistory(),
  114. routes: localRoutes,
  115. }) as MockedRouter
  116. // cannot use "as const" here, because ESLint fails with obscure error :shrug:
  117. const methods = ['push', 'replace', 'back', 'go', 'forward'] as unknown as [
  118. 'push',
  119. ]
  120. methods.forEach((name) => vi.spyOn(router, name))
  121. router.mockMethods = () => {
  122. methods.forEach((name) =>
  123. vi.mocked(router[name]).mockImplementation(() => Promise.resolve()),
  124. )
  125. }
  126. router.restoreMethods = () => {
  127. methods.forEach((name) => vi.mocked(router[name]).mockRestore())
  128. }
  129. plugins.push(router)
  130. plugins.push({
  131. install(app) {
  132. initializeWalker(app, router)
  133. },
  134. })
  135. defaultWrapperOptions.global ||= {}
  136. defaultWrapperOptions.global.stubs ||= {}
  137. Object.assign(defaultWrapperOptions.global.stubs, {
  138. RouterLink: false,
  139. })
  140. routerInitialized = true
  141. }
  142. let storeInitialized = false
  143. export const initializePiniaStore = () => {
  144. if (storeInitialized) return
  145. const store = initializeStore()
  146. plugins.push({ install: store.install })
  147. storeInitialized = true
  148. }
  149. let formInitialized = false
  150. const initializeForm = () => {
  151. if (formInitialized) return
  152. // TODO: needs to be extended, when we have app specific plugins/fields
  153. plugins.push([formPlugin, buildFormKitPluginConfig()])
  154. defaultWrapperOptions.shallow = false
  155. formInitialized = true
  156. }
  157. let applicationConfigInitialized = false
  158. const initializeApplicationConfig = () => {
  159. if (applicationConfigInitialized) return
  160. initializePiniaStore()
  161. plugins.push(applicationConfigPlugin)
  162. applicationConfigInitialized = true
  163. }
  164. const wrappers = new Set<[ExtendedMountingOptions<any>, ExtendedRenderResult]>()
  165. export const cleanup = () => {
  166. wrappers.forEach((wrapper) => {
  167. const [{ unmount = true }, view] = wrapper
  168. if (unmount) {
  169. view.unmount()
  170. wrappers.delete(wrapper)
  171. }
  172. })
  173. cleanupStores()
  174. }
  175. globalThis.cleanupComponents = cleanup
  176. let dialogMounted = false
  177. const mountDialog = () => {
  178. if (dialogMounted) return
  179. const Dialog = {
  180. components: { DynamicInitializer },
  181. template: '<DynamicInitializer name="dialog" />',
  182. } as any
  183. const { element } = mount(Dialog, defaultWrapperOptions)
  184. document.body.appendChild(element)
  185. dialogMounted = true
  186. }
  187. let imageViewerMounted = false
  188. const mountImageViewer = () => {
  189. if (imageViewerMounted) return
  190. const ImageViewer = {
  191. components: { CommonImageViewer },
  192. template: '<CommonImageViewer />',
  193. } as any
  194. const { element } = mount(ImageViewer, defaultWrapperOptions)
  195. document.body.appendChild(element)
  196. imageViewerMounted = true
  197. }
  198. afterEach(() => {
  199. router?.restoreMethods()
  200. imageViewerOptions.value = {
  201. visible: false,
  202. index: 0,
  203. images: [],
  204. }
  205. })
  206. let confirmationMounted = false
  207. const mountconfirmation = () => {
  208. if (confirmationMounted) return
  209. const Confirmation = {
  210. components: { CommonConfirmation },
  211. template: '<CommonConfirmation />',
  212. } as any
  213. const { element } = mount(Confirmation, defaultWrapperOptions)
  214. document.body.appendChild(element)
  215. confirmationMounted = true
  216. }
  217. const setupVModel = <Props>(wrapperOptions: ExtendedMountingOptions<Props>) => {
  218. const vModelProps: [string, Ref][] = []
  219. const vModelOptions = Object.entries(wrapperOptions?.vModel || {})
  220. for (const [prop, propDefault] of vModelOptions) {
  221. const reactiveValue = isRef(propDefault) ? propDefault : ref(propDefault)
  222. const props = (wrapperOptions.props ?? {}) as any
  223. props[prop] = unref(propDefault)
  224. props[`onUpdate:${prop}`] = (value: unknown) => {
  225. reactiveValue.value = value
  226. }
  227. vModelProps.push([prop, reactiveValue])
  228. wrapperOptions.props = props
  229. }
  230. const startWatchingModel = (view: ExtendedRenderResult) => {
  231. if (!vModelProps.length) return
  232. watchEffect(() => {
  233. const propsValues = vModelProps.reduce((acc, [prop, reactiveValue]) => {
  234. acc[prop] = reactiveValue.value
  235. return acc
  236. }, {} as Record<string, unknown>)
  237. view.rerender(propsValues)
  238. })
  239. }
  240. return {
  241. startWatchingModel,
  242. }
  243. }
  244. const renderComponent = <Props>(
  245. component: any,
  246. wrapperOptions: ExtendedMountingOptions<Props> = {},
  247. ): ExtendedRenderResult => {
  248. // Store and Router needs only to be initalized once for a test suit.
  249. if (wrapperOptions?.router) {
  250. initializeRouter(wrapperOptions?.routerRoutes)
  251. }
  252. if (wrapperOptions?.store) {
  253. initializePiniaStore()
  254. }
  255. if (wrapperOptions?.form) {
  256. initializeForm()
  257. }
  258. if (wrapperOptions?.dialog) {
  259. mountDialog()
  260. }
  261. if (wrapperOptions?.imageViewer) {
  262. mountImageViewer()
  263. }
  264. if (wrapperOptions?.confirmation) {
  265. mountconfirmation()
  266. }
  267. initializeApplicationConfig()
  268. initializeObjectAttributes()
  269. if (wrapperOptions?.form && wrapperOptions?.formField) {
  270. defaultWrapperOptions.props ||= {}
  271. // Reset the defult of 20ms for testing.
  272. defaultWrapperOptions.props.delay = 0
  273. }
  274. const { startWatchingModel } = setupVModel(wrapperOptions)
  275. const localWrapperOptions: ExtendedMountingOptions<Props> = merge(
  276. cloneDeep(defaultWrapperOptions),
  277. wrapperOptions,
  278. )
  279. // @testing-library consoles a warning, if these options are present
  280. delete localWrapperOptions.router
  281. delete localWrapperOptions.store
  282. const view = render(component, localWrapperOptions) as ExtendedRenderResult
  283. const events = userEvent.setup({
  284. advanceTimers(delay) {
  285. try {
  286. vi.advanceTimersByTime(delay)
  287. // eslint-disable-next-line no-empty
  288. } catch {}
  289. },
  290. })
  291. view.events = {
  292. ...events,
  293. async debounced(cb, ms) {
  294. vi.useFakeTimers()
  295. await cb()
  296. if (ms) {
  297. vi.advanceTimersByTime(ms)
  298. } else {
  299. vi.runAllTimers()
  300. }
  301. vi.useRealTimers()
  302. await waitForNextTick()
  303. await nextTick()
  304. },
  305. }
  306. Object.assign(view, buildIconsQueries(view.baseElement as HTMLElement))
  307. Object.assign(view, buildLinksQueries(view.baseElement as HTMLElement))
  308. wrappers.add([localWrapperOptions, view])
  309. startWatchingModel(view)
  310. return view
  311. }
  312. export default renderComponent