renderComponent.ts 15 KB

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