renderComponent.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532
  1. // Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
  2. // import of these files takes 2.5 seconds for each test file!
  3. // need to optimize this somehow
  4. import { plugin as formPlugin } from '@formkit/vue'
  5. import userEvent from '@testing-library/user-event'
  6. import { render } from '@testing-library/vue'
  7. import { mount } from '@vue/test-utils'
  8. import { merge, cloneDeep } from 'lodash-es'
  9. import { isRef, nextTick, ref, watchEffect, unref } from 'vue'
  10. import { createRouter, createWebHistory } from 'vue-router'
  11. import CommonAlert from '#shared/components/CommonAlert/CommonAlert.vue'
  12. import CommonBadge from '#shared/components/CommonBadge/CommonBadge.vue'
  13. import CommonDateTime from '#shared/components/CommonDateTime/CommonDateTime.vue'
  14. import CommonIcon from '#shared/components/CommonIcon/CommonIcon.vue'
  15. import { provideIcons } from '#shared/components/CommonIcon/useIcons.ts'
  16. import CommonLabel from '#shared/components/CommonLabel/CommonLabel.vue'
  17. import CommonLink from '#shared/components/CommonLink/CommonLink.vue'
  18. import DynamicInitializer from '#shared/components/DynamicInitializer/DynamicInitializer.vue'
  19. import { initializeAppName } from '#shared/composables/useAppName.ts'
  20. import { imageViewerOptions } from '#shared/composables/useImageViewer.ts'
  21. import {
  22. setupCommonVisualConfig,
  23. type SharedVisualConfig,
  24. } from '#shared/composables/useSharedVisualConfig.ts'
  25. import { initializeTwoFactorPlugins } from '#shared/entities/two-factor/composables/initializeTwoFactorPlugins.ts'
  26. import { buildFormKitPluginConfig } from '#shared/form/index.ts'
  27. import { i18n } from '#shared/i18n.ts'
  28. import applicationConfigPlugin from '#shared/plugins/applicationConfigPlugin.ts'
  29. import { initializeWalker } from '#shared/router/walker.ts'
  30. import type { AppName } from '#shared/types/app.ts'
  31. import type { FormFieldTypeImportModules } from '#shared/types/form.ts'
  32. import type { ImportGlobEagerOutput } from '#shared/types/utils.ts'
  33. import { twoFactorConfigurationPluginLookup } from '#desktop/entities/two-factor-configuration/plugins/index.ts'
  34. import desktopIconsAliases from '#desktop/initializer/desktopIconsAliasesMap.ts'
  35. import { directives } from '#desktop/initializer/initializeGlobalDirectives.ts'
  36. import mobileIconsAliases from '#mobile/initializer/mobileIconsAliasesMap.ts'
  37. import { setTestState, waitForNextTick } from '../utils.ts'
  38. import { getTestAppName } from './app.ts'
  39. import buildIconsQueries from './iconQueries.ts'
  40. import { cleanupStores, initializeStore } from './initializeStore.ts'
  41. import buildLinksQueries from './linkQueries.ts'
  42. import type { Matcher, RenderResult } from '@testing-library/vue'
  43. import type { ComponentMountingOptions } from '@vue/test-utils'
  44. import type { Plugin, Ref } from 'vue'
  45. import type { Router, RouteRecordRaw, NavigationGuard } from 'vue-router'
  46. const appName = getTestAppName()
  47. const isMobile = appName !== 'desktop'
  48. const isDesktop = appName === 'desktop'
  49. // not eager because we don't actually want to import all those components, we only need their names
  50. const icons = isDesktop
  51. ? import.meta.glob('../../../apps/desktop/initializer/assets/*.svg')
  52. : import.meta.glob('../../../apps/mobile/initializer/assets/*.svg')
  53. provideIcons(
  54. Object.keys(icons).map((icon) => [icon, { default: '' }]),
  55. isDesktop ? desktopIconsAliases : mobileIconsAliases,
  56. )
  57. // internal Vitest variable, ideally should check expect.getState().testPath, but it's not populated in 0.34.6 (a bug)
  58. const { filepath } = (globalThis as any).__vitest_worker__ as any
  59. let formFields: ImportGlobEagerOutput<FormFieldTypeImportModules>
  60. let ConformationComponent: unknown
  61. let initDefaultVisuals: () => void
  62. // TODO: have a separate check for shared components
  63. if (isMobile) {
  64. const [
  65. { default: CommonConfirmation },
  66. { initializeMobileVisuals },
  67. { mobileFormFieldModules },
  68. ] = await Promise.all([
  69. import('#mobile/components/CommonConfirmation/CommonConfirmation.vue'),
  70. import('#mobile/initializer/mobileVisuals.ts'),
  71. import('#mobile/form/index.ts'),
  72. ])
  73. initDefaultVisuals = initializeMobileVisuals
  74. ConformationComponent = CommonConfirmation
  75. formFields = mobileFormFieldModules
  76. } else if (isDesktop) {
  77. const [{ initializeDesktopVisuals }, { desktopFormFieldModules }] =
  78. await Promise.all([
  79. import('#desktop/initializer/desktopVisuals.ts'),
  80. import('#desktop/form/index.ts'),
  81. ])
  82. initDefaultVisuals = initializeDesktopVisuals
  83. formFields = desktopFormFieldModules
  84. } else {
  85. throw new Error(`Was not able to detect the app type from ${filepath} test.`)
  86. }
  87. // TODO: some things can be handled differently: https://test-utils.vuejs.org/api/#config-global
  88. export interface ExtendedMountingOptions<Props>
  89. extends ComponentMountingOptions<Props> {
  90. router?: boolean
  91. routerRoutes?: RouteRecordRaw[]
  92. routerBeforeGuards?: NavigationGuard[]
  93. store?: boolean
  94. confirmation?: boolean
  95. form?: boolean
  96. formField?: boolean
  97. unmount?: boolean
  98. dialog?: boolean
  99. flyout?: boolean
  100. app?: AppName
  101. vModel?: {
  102. [prop: string]: unknown
  103. }
  104. visuals?: SharedVisualConfig
  105. }
  106. type UserEvent = ReturnType<(typeof userEvent)['setup']>
  107. interface PageEvents extends UserEvent {
  108. debounced(fn: () => unknown, ms?: number): Promise<void>
  109. }
  110. export interface ExtendedRenderResult extends RenderResult {
  111. events: PageEvents
  112. router: Router
  113. queryAllByIconName(matcher: Matcher): SVGElement[]
  114. queryByIconName(matcher: Matcher): SVGElement | null
  115. getAllByIconName(matcher: Matcher): SVGElement[]
  116. getByIconName(matcher: Matcher): SVGElement
  117. findAllByIconName(matcher: Matcher): Promise<SVGElement[]>
  118. findByIconName(matcher: Matcher): Promise<SVGElement>
  119. getLinkFromElement(element: Element): HTMLAnchorElement
  120. }
  121. const plugins: (Plugin | [Plugin, ...unknown[]])[] = [
  122. (app) => {
  123. app.config.globalProperties.i18n = i18n
  124. app.config.globalProperties.$t = i18n.t.bind(i18n)
  125. app.config.globalProperties.__ = (source: string) => source
  126. },
  127. ]
  128. const defaultWrapperOptions: ExtendedMountingOptions<unknown> = {
  129. global: {
  130. components: {
  131. CommonAlert,
  132. CommonIcon,
  133. CommonLink,
  134. CommonDateTime,
  135. CommonLabel,
  136. CommonBadge,
  137. },
  138. directives,
  139. stubs: {},
  140. plugins,
  141. },
  142. }
  143. interface MockedRouter extends Router {
  144. mockMethods(): void
  145. restoreMethods(): void
  146. }
  147. let routerInitialized = false
  148. let router: MockedRouter
  149. export const getTestPlugins = () => [...plugins]
  150. export const getTestRouter = () => router
  151. // cannot use "as const" here, because ESLint fails with obscure error :shrug:
  152. const routerMethods = [
  153. 'push',
  154. 'replace',
  155. 'back',
  156. 'go',
  157. 'forward',
  158. ] as unknown as ['push']
  159. const ensureRouterSpy = () => {
  160. if (!router) return
  161. routerMethods.forEach((name) => vi.spyOn(router, name))
  162. }
  163. const initializeRouter = (
  164. routes?: RouteRecordRaw[],
  165. routerBeforeGuards?: NavigationGuard[],
  166. ) => {
  167. if (routerInitialized) {
  168. ensureRouterSpy()
  169. return
  170. }
  171. let localRoutes: RouteRecordRaw[] = [
  172. {
  173. name: 'Dashboard',
  174. path: '/',
  175. component: {
  176. template: 'Welcome to zammad.',
  177. },
  178. },
  179. {
  180. name: 'Example',
  181. path: '/example',
  182. component: {
  183. template: 'This is a example page.',
  184. },
  185. },
  186. {
  187. name: 'Error',
  188. path: '/:pathMatch(.*)*',
  189. component: {
  190. template: 'Error page',
  191. },
  192. },
  193. ]
  194. // Use only the default routes, if nothing was given.
  195. if (routes) {
  196. localRoutes = routes
  197. }
  198. router = createRouter({
  199. history: createWebHistory(isDesktop ? '/desktop' : '/mobile'),
  200. routes: localRoutes,
  201. }) as MockedRouter
  202. routerBeforeGuards?.forEach((guard) => router.beforeEach(guard))
  203. Object.defineProperty(globalThis, 'Router', {
  204. value: router,
  205. writable: true,
  206. configurable: true,
  207. })
  208. ensureRouterSpy()
  209. router.mockMethods = () => {
  210. routerMethods.forEach((name) =>
  211. vi.mocked(router[name]).mockImplementation(() => Promise.resolve()),
  212. )
  213. }
  214. router.restoreMethods = () => {
  215. routerMethods.forEach((name) => {
  216. if (vi.isMockFunction(router[name])) {
  217. vi.mocked(router[name]).mockRestore()
  218. }
  219. })
  220. }
  221. plugins.push(router)
  222. plugins.push({
  223. install(app) {
  224. initializeWalker(app, router)
  225. },
  226. })
  227. defaultWrapperOptions.global ||= {}
  228. defaultWrapperOptions.global.stubs ||= {}
  229. Object.assign(defaultWrapperOptions.global.stubs, {
  230. RouterLink: false,
  231. })
  232. routerInitialized = true
  233. }
  234. let storeInitialized = false
  235. export const initializePiniaStore = () => {
  236. if (storeInitialized) return
  237. const store = initializeStore()
  238. plugins.push({ install: store.install })
  239. storeInitialized = true
  240. }
  241. let formInitialized = false
  242. const initializeForm = () => {
  243. if (formInitialized) return
  244. plugins.push([formPlugin, buildFormKitPluginConfig(undefined, formFields)])
  245. defaultWrapperOptions.shallow = false
  246. formInitialized = true
  247. }
  248. let applicationConfigInitialized = false
  249. const initializeApplicationConfig = () => {
  250. if (applicationConfigInitialized) return
  251. initializePiniaStore()
  252. plugins.push(applicationConfigPlugin)
  253. if (isDesktop) {
  254. initializeTwoFactorPlugins(twoFactorConfigurationPluginLookup)
  255. }
  256. applicationConfigInitialized = true
  257. }
  258. const wrappers = new Set<[ExtendedMountingOptions<any>, ExtendedRenderResult]>()
  259. export const cleanup = () => {
  260. wrappers.forEach((wrapper) => {
  261. const [{ unmount = true }, view] = wrapper
  262. if (unmount) {
  263. view.unmount()
  264. wrappers.delete(wrapper)
  265. }
  266. })
  267. cleanupStores()
  268. }
  269. globalThis.cleanupComponents = cleanup
  270. let dialogMounted = false
  271. const mountDialog = () => {
  272. if (dialogMounted) return
  273. const Dialog = {
  274. components: { DynamicInitializer },
  275. template: '<DynamicInitializer name="dialog" />',
  276. } as any
  277. const { element } = mount(Dialog, defaultWrapperOptions)
  278. document.body.appendChild(element)
  279. dialogMounted = true
  280. }
  281. let flyoutMounted = false
  282. const mountFlyout = () => {
  283. if (flyoutMounted) return
  284. const Dialog = {
  285. components: { DynamicInitializer },
  286. template: '<DynamicInitializer name="flyout" />',
  287. } as any
  288. const { element } = mount(Dialog, defaultWrapperOptions)
  289. document.body.appendChild(element)
  290. flyoutMounted = true
  291. }
  292. setTestState({
  293. imageViewerOptions,
  294. })
  295. afterEach(() => {
  296. router?.restoreMethods()
  297. imageViewerOptions.value = {
  298. visible: false,
  299. index: 0,
  300. images: [],
  301. }
  302. })
  303. let confirmationMounted = false
  304. const mountConfirmation = () => {
  305. if (confirmationMounted) return
  306. if (!ConformationComponent) {
  307. throw new Error('ConformationComponent is not defined.')
  308. }
  309. const Confirmation = {
  310. components: { CommonConfirmation: ConformationComponent },
  311. template: '<CommonConfirmation />',
  312. } as any
  313. const { element } = mount(Confirmation, defaultWrapperOptions)
  314. document.body.appendChild(element)
  315. confirmationMounted = true
  316. }
  317. const setupVModel = <Props>(wrapperOptions: ExtendedMountingOptions<Props>) => {
  318. const vModelProps: [string, Ref][] = []
  319. const vModelOptions = Object.entries(wrapperOptions?.vModel || {})
  320. for (const [prop, propDefault] of vModelOptions) {
  321. const reactiveValue = isRef(propDefault) ? propDefault : ref(propDefault)
  322. const props = (wrapperOptions.props ?? {}) as any
  323. props[prop] = unref(propDefault)
  324. props[`onUpdate:${prop}`] = (value: unknown) => {
  325. reactiveValue.value = value
  326. }
  327. vModelProps.push([prop, reactiveValue])
  328. wrapperOptions.props = props
  329. }
  330. const startWatchingModel = (view: ExtendedRenderResult) => {
  331. if (!vModelProps.length) return
  332. watchEffect(() => {
  333. const propsValues = vModelProps.reduce(
  334. (acc, [prop, reactiveValue]) => {
  335. acc[prop] = reactiveValue.value
  336. return acc
  337. },
  338. {} as Record<string, unknown>,
  339. )
  340. view.rerender(propsValues)
  341. })
  342. }
  343. return {
  344. startWatchingModel,
  345. }
  346. }
  347. const renderComponent = <Props>(
  348. component: any,
  349. wrapperOptions: ExtendedMountingOptions<Props> = {},
  350. ): ExtendedRenderResult => {
  351. initializeAppName(appName)
  352. // Store and Router needs only to be initalized once for a test suit.
  353. if (wrapperOptions?.router) {
  354. initializeRouter(
  355. wrapperOptions?.routerRoutes,
  356. wrapperOptions?.routerBeforeGuards,
  357. )
  358. }
  359. if (wrapperOptions?.store) {
  360. initializePiniaStore()
  361. }
  362. if (wrapperOptions?.form) {
  363. initializeForm()
  364. }
  365. if (wrapperOptions?.dialog) {
  366. mountDialog()
  367. }
  368. if (wrapperOptions?.flyout) {
  369. mountFlyout()
  370. }
  371. if (wrapperOptions?.confirmation) {
  372. mountConfirmation()
  373. }
  374. initializeApplicationConfig()
  375. if (wrapperOptions.visuals) {
  376. setupCommonVisualConfig(wrapperOptions.visuals)
  377. } else {
  378. initDefaultVisuals()
  379. }
  380. if (wrapperOptions?.form && wrapperOptions?.formField) {
  381. defaultWrapperOptions.props ||= {}
  382. // Reset the default of 20ms for testing.
  383. defaultWrapperOptions.props.delay = 0
  384. }
  385. const { startWatchingModel } = setupVModel(wrapperOptions)
  386. const localWrapperOptions: ExtendedMountingOptions<Props> = merge(
  387. cloneDeep(defaultWrapperOptions),
  388. wrapperOptions,
  389. )
  390. // @testing-library consoles a warning, if these options are present
  391. delete localWrapperOptions.router
  392. delete localWrapperOptions.store
  393. const view = render(component, localWrapperOptions) as ExtendedRenderResult
  394. const events = userEvent.setup({
  395. advanceTimers(delay) {
  396. if (vi.isFakeTimers()) {
  397. vi.advanceTimersByTime(delay)
  398. }
  399. },
  400. })
  401. view.events = {
  402. ...events,
  403. async debounced(cb, ms) {
  404. vi.useFakeTimers()
  405. await cb()
  406. if (ms) {
  407. vi.advanceTimersByTime(ms)
  408. } else {
  409. vi.runAllTimers()
  410. }
  411. vi.useRealTimers()
  412. await waitForNextTick()
  413. await nextTick()
  414. },
  415. }
  416. Object.assign(view, buildIconsQueries(view.baseElement as HTMLElement))
  417. Object.assign(view, buildLinksQueries(view.baseElement as HTMLElement))
  418. wrappers.add([localWrapperOptions, view])
  419. startWatchingModel(view)
  420. Object.defineProperty(view, 'router', {
  421. get() {
  422. return router
  423. },
  424. enumerable: true,
  425. configurable: true,
  426. })
  427. return view
  428. }
  429. export default renderComponent