renderComponent.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540
  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 tooltip from '#shared/plugins/directives/tooltip/index.ts'
  30. import { initializeWalker } from '#shared/router/walker.ts'
  31. import type { AppName } from '#shared/types/app.ts'
  32. import type { FormFieldTypeImportModules } from '#shared/types/form.ts'
  33. import type { ImportGlobEagerOutput } from '#shared/types/utils.ts'
  34. import { twoFactorConfigurationPluginLookup } from '#desktop/entities/two-factor-configuration/plugins/index.ts'
  35. import desktopIconsAliases from '#desktop/initializer/desktopIconsAliasesMap.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. plugins?: Plugin[]
  106. }
  107. type UserEvent = ReturnType<(typeof userEvent)['setup']>
  108. interface PageEvents extends UserEvent {
  109. debounced(fn: () => unknown, ms?: number): Promise<void>
  110. }
  111. export interface ExtendedRenderResult extends RenderResult {
  112. events: PageEvents
  113. router: Router
  114. queryAllByIconName(matcher: Matcher): SVGElement[]
  115. queryByIconName(matcher: Matcher): SVGElement | null
  116. getAllByIconName(matcher: Matcher): SVGElement[]
  117. getByIconName(matcher: Matcher): SVGElement
  118. findAllByIconName(matcher: Matcher): Promise<SVGElement[]>
  119. findByIconName(matcher: Matcher): Promise<SVGElement>
  120. getLinkFromElement(element: Element): HTMLAnchorElement
  121. }
  122. const plugins: (Plugin | [Plugin, ...unknown[]])[] = [
  123. (app) => {
  124. app.config.globalProperties.i18n = i18n
  125. app.config.globalProperties.$t = i18n.t.bind(i18n)
  126. app.config.globalProperties.__ = (source: string) => source
  127. },
  128. ]
  129. const defaultWrapperOptions: ExtendedMountingOptions<unknown> = {
  130. global: {
  131. components: {
  132. CommonAlert,
  133. CommonIcon,
  134. CommonLink,
  135. CommonDateTime,
  136. CommonLabel,
  137. CommonBadge,
  138. },
  139. stubs: {},
  140. directives: { [tooltip.name]: tooltip.directive },
  141. plugins,
  142. },
  143. }
  144. interface MockedRouter extends Router {
  145. mockMethods(): void
  146. restoreMethods(): void
  147. }
  148. let routerInitialized = false
  149. let router: MockedRouter
  150. export const getTestPlugins = () => [...plugins]
  151. export const getTestRouter = () => router
  152. // cannot use "as const" here, because ESLint fails with obscure error :shrug:
  153. const routerMethods = [
  154. 'push',
  155. 'replace',
  156. 'back',
  157. 'go',
  158. 'forward',
  159. ] as unknown as ['push']
  160. const ensureRouterSpy = () => {
  161. if (!router) return
  162. routerMethods.forEach((name) => vi.spyOn(router, name))
  163. }
  164. const initializeRouter = (
  165. routes?: RouteRecordRaw[],
  166. routerBeforeGuards?: NavigationGuard[],
  167. ) => {
  168. if (routerInitialized) {
  169. ensureRouterSpy()
  170. return
  171. }
  172. let localRoutes: RouteRecordRaw[] = [
  173. {
  174. name: 'Dashboard',
  175. path: '/',
  176. component: {
  177. template: 'Welcome to zammad.',
  178. },
  179. },
  180. {
  181. name: 'Example',
  182. path: '/example',
  183. component: {
  184. template: 'This is a example page.',
  185. },
  186. },
  187. {
  188. name: 'Error',
  189. path: '/:pathMatch(.*)*',
  190. component: {
  191. template: 'Error page',
  192. },
  193. },
  194. ]
  195. // Use only the default routes, if nothing was given.
  196. if (routes) {
  197. localRoutes = routes
  198. }
  199. router = createRouter({
  200. history: createWebHistory(isDesktop ? '/desktop' : '/mobile'),
  201. routes: localRoutes,
  202. }) as MockedRouter
  203. routerBeforeGuards?.forEach((guard) => router.beforeEach(guard))
  204. Object.defineProperty(globalThis, 'Router', {
  205. value: router,
  206. writable: true,
  207. configurable: true,
  208. })
  209. ensureRouterSpy()
  210. router.mockMethods = () => {
  211. routerMethods.forEach((name) =>
  212. vi.mocked(router[name]).mockImplementation(() => Promise.resolve()),
  213. )
  214. }
  215. router.restoreMethods = () => {
  216. routerMethods.forEach((name) => {
  217. if (vi.isMockFunction(router[name])) {
  218. vi.mocked(router[name]).mockRestore()
  219. }
  220. })
  221. }
  222. plugins.push(router)
  223. plugins.push({
  224. install(app) {
  225. initializeWalker(app, router)
  226. },
  227. })
  228. defaultWrapperOptions.global ||= {}
  229. defaultWrapperOptions.global.stubs ||= {}
  230. Object.assign(defaultWrapperOptions.global.stubs, {
  231. RouterLink: false,
  232. })
  233. routerInitialized = true
  234. }
  235. let storeInitialized = false
  236. export const initializePiniaStore = () => {
  237. if (storeInitialized) return
  238. const store = initializeStore()
  239. plugins.push({ install: store.install })
  240. storeInitialized = true
  241. }
  242. let formInitialized = false
  243. const initializeForm = () => {
  244. if (formInitialized) return
  245. plugins.push([formPlugin, buildFormKitPluginConfig(undefined, formFields)])
  246. defaultWrapperOptions.shallow = false
  247. formInitialized = true
  248. }
  249. let applicationConfigInitialized = false
  250. const initializeApplicationConfig = () => {
  251. if (applicationConfigInitialized) return
  252. initializePiniaStore()
  253. plugins.push(applicationConfigPlugin)
  254. if (isDesktop) {
  255. initializeTwoFactorPlugins(twoFactorConfigurationPluginLookup)
  256. }
  257. applicationConfigInitialized = true
  258. }
  259. const wrappers = new Set<[ExtendedMountingOptions<any>, ExtendedRenderResult]>()
  260. export const cleanup = () => {
  261. wrappers.forEach((wrapper) => {
  262. const [{ unmount = true }, view] = wrapper
  263. if (unmount) {
  264. view.unmount()
  265. wrappers.delete(wrapper)
  266. }
  267. })
  268. cleanupStores()
  269. }
  270. globalThis.cleanupComponents = cleanup
  271. let dialogMounted = false
  272. const mountDialog = () => {
  273. if (dialogMounted) return
  274. const Dialog = {
  275. components: { DynamicInitializer },
  276. template: '<DynamicInitializer name="dialog" />',
  277. } as any
  278. const { element } = mount(Dialog, defaultWrapperOptions)
  279. document.body.appendChild(element)
  280. dialogMounted = true
  281. }
  282. let flyoutMounted = false
  283. const mountFlyout = () => {
  284. if (flyoutMounted) return
  285. const Flyout = {
  286. components: { DynamicInitializer },
  287. template: '<DynamicInitializer name="flyout" />',
  288. } as any
  289. const { element } = mount(Flyout, defaultWrapperOptions)
  290. document.body.appendChild(element)
  291. document.body.id = 'app' // used to teleport the flyout
  292. flyoutMounted = true
  293. }
  294. setTestState({
  295. imageViewerOptions,
  296. })
  297. afterEach(() => {
  298. router?.restoreMethods()
  299. imageViewerOptions.value = {
  300. visible: false,
  301. index: 0,
  302. images: [],
  303. }
  304. })
  305. let confirmationMounted = false
  306. const mountConfirmation = () => {
  307. if (confirmationMounted) return
  308. if (!ConformationComponent) {
  309. throw new Error('ConformationComponent is not defined.')
  310. }
  311. const Confirmation = {
  312. components: { CommonConfirmation: ConformationComponent },
  313. template: '<CommonConfirmation />',
  314. } as any
  315. const { element } = mount(Confirmation, defaultWrapperOptions)
  316. document.body.appendChild(element)
  317. confirmationMounted = true
  318. }
  319. const setupVModel = <Props>(wrapperOptions: ExtendedMountingOptions<Props>) => {
  320. const vModelProps: [string, Ref][] = []
  321. const vModelOptions = Object.entries(wrapperOptions?.vModel || {})
  322. for (const [prop, propDefault] of vModelOptions) {
  323. const reactiveValue = isRef(propDefault) ? propDefault : ref(propDefault)
  324. const props = (wrapperOptions.props ?? {}) as any
  325. props[prop] = unref(propDefault)
  326. props[`onUpdate:${prop}`] = (value: unknown) => {
  327. reactiveValue.value = value
  328. }
  329. vModelProps.push([prop, reactiveValue])
  330. wrapperOptions.props = props
  331. }
  332. const startWatchingModel = (view: ExtendedRenderResult) => {
  333. if (!vModelProps.length) return
  334. watchEffect(() => {
  335. const propsValues = vModelProps.reduce(
  336. (acc, [prop, reactiveValue]) => {
  337. acc[prop] = reactiveValue.value
  338. return acc
  339. },
  340. {} as Record<string, unknown>,
  341. )
  342. view.rerender(propsValues)
  343. })
  344. }
  345. return {
  346. startWatchingModel,
  347. }
  348. }
  349. const renderComponent = <Props>(
  350. component: any,
  351. wrapperOptions: ExtendedMountingOptions<Props> = {},
  352. ): ExtendedRenderResult => {
  353. initializeAppName(appName)
  354. // Store and Router needs only to be initalized once for a test suit.
  355. if (wrapperOptions.router) {
  356. initializeRouter(
  357. wrapperOptions.routerRoutes,
  358. wrapperOptions.routerBeforeGuards,
  359. )
  360. }
  361. if (wrapperOptions.store) {
  362. initializePiniaStore()
  363. }
  364. if (wrapperOptions.form) {
  365. initializeForm()
  366. }
  367. if (wrapperOptions.dialog) {
  368. mountDialog()
  369. }
  370. if (wrapperOptions.flyout) {
  371. mountFlyout()
  372. }
  373. if (wrapperOptions.confirmation) {
  374. mountConfirmation()
  375. }
  376. initializeApplicationConfig()
  377. if (wrapperOptions.visuals) {
  378. setupCommonVisualConfig(wrapperOptions.visuals)
  379. } else {
  380. initDefaultVisuals()
  381. }
  382. if (wrapperOptions.form && wrapperOptions.formField) {
  383. defaultWrapperOptions.props ||= {}
  384. // Reset the default of 20ms for testing.
  385. defaultWrapperOptions.props.delay = 0
  386. }
  387. if (wrapperOptions.plugins) {
  388. plugins.push(...wrapperOptions.plugins)
  389. delete wrapperOptions.plugins
  390. }
  391. const { startWatchingModel } = setupVModel(wrapperOptions)
  392. const localWrapperOptions: ExtendedMountingOptions<Props> = merge(
  393. cloneDeep(defaultWrapperOptions),
  394. wrapperOptions,
  395. )
  396. // @testing-library consoles a warning, if these options are present
  397. delete localWrapperOptions.router
  398. delete localWrapperOptions.store
  399. const view = render(component, localWrapperOptions) as ExtendedRenderResult
  400. const events = userEvent.setup({
  401. advanceTimers(delay) {
  402. if (vi.isFakeTimers()) {
  403. vi.advanceTimersByTime(delay)
  404. }
  405. },
  406. })
  407. view.events = {
  408. ...events,
  409. async debounced(cb, ms) {
  410. vi.useFakeTimers()
  411. await cb()
  412. if (ms) {
  413. vi.advanceTimersByTime(ms)
  414. } else {
  415. vi.runAllTimers()
  416. }
  417. vi.useRealTimers()
  418. await waitForNextTick()
  419. await nextTick()
  420. },
  421. }
  422. Object.assign(view, buildIconsQueries(view.baseElement as HTMLElement))
  423. Object.assign(view, buildLinksQueries(view.baseElement as HTMLElement))
  424. wrappers.add([localWrapperOptions, view])
  425. startWatchingModel(view)
  426. Object.defineProperty(view, 'router', {
  427. get() {
  428. return router
  429. },
  430. enumerable: true,
  431. configurable: true,
  432. })
  433. return view
  434. }
  435. export default renderComponent