renderComponent.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568
  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. export const getTestPlugins = () => [...plugins]
  162. export const getTestRouter = () => router
  163. // cannot use "as const" here, because ESLint fails with obscure error :shrug:
  164. const routerMethods = [
  165. 'push',
  166. 'replace',
  167. 'back',
  168. 'go',
  169. 'forward',
  170. ] as unknown as ['push']
  171. const ensureRouterSpy = () => {
  172. if (!router) return
  173. routerMethods.forEach((name) => vi.spyOn(router, name))
  174. }
  175. const initializeRouter = (
  176. routes?: RouteRecordRaw[],
  177. routerBeforeGuards?: NavigationGuard[],
  178. ) => {
  179. if (routerInitialized) {
  180. ensureRouterSpy()
  181. return
  182. }
  183. let localRoutes: RouteRecordRaw[] = [
  184. {
  185. name: 'Dashboard',
  186. path: '/',
  187. component: {
  188. template: 'Welcome to zammad.',
  189. },
  190. },
  191. {
  192. name: 'Example',
  193. path: '/example',
  194. component: {
  195. template: 'This is a example page.',
  196. },
  197. },
  198. {
  199. name: 'Error',
  200. path: '/:pathMatch(.*)*',
  201. component: {
  202. template: 'Error page',
  203. },
  204. },
  205. ]
  206. // Use only the default routes, if nothing was given.
  207. if (routes) {
  208. localRoutes = routes
  209. }
  210. router = createRouter({
  211. history: createWebHistory(isDesktop ? '/desktop' : '/mobile'),
  212. routes: localRoutes,
  213. }) as MockedRouter
  214. routerBeforeGuards?.forEach((guard) => router.beforeEach(guard))
  215. Object.defineProperty(globalThis, 'Router', {
  216. value: router,
  217. writable: true,
  218. configurable: true,
  219. })
  220. ensureRouterSpy()
  221. router.mockMethods = () => {
  222. routerMethods.forEach((name) =>
  223. vi.mocked(router[name]).mockImplementation(() => Promise.resolve()),
  224. )
  225. }
  226. router.restoreMethods = () => {
  227. routerMethods.forEach((name) => {
  228. if (vi.isMockFunction(router[name])) {
  229. vi.mocked(router[name]).mockRestore()
  230. }
  231. })
  232. }
  233. plugins.push(router)
  234. plugins.push({
  235. install(app) {
  236. initializeWalker(app, router)
  237. },
  238. })
  239. defaultWrapperOptions.global ||= {}
  240. defaultWrapperOptions.global.stubs ||= {}
  241. Object.assign(defaultWrapperOptions.global.stubs, {
  242. RouterLink: false,
  243. })
  244. routerInitialized = true
  245. }
  246. let storeInitialized = false
  247. export const initializePiniaStore = () => {
  248. if (storeInitialized) return
  249. const store = initializeStore()
  250. plugins.push({ install: store.install })
  251. storeInitialized = true
  252. }
  253. let formInitialized = false
  254. const initializeForm = () => {
  255. if (formInitialized) return
  256. plugins.push([formPlugin, buildFormKitPluginConfig(undefined, formFields)])
  257. defaultWrapperOptions.shallow = false
  258. formInitialized = true
  259. }
  260. let applicationConfigInitialized = false
  261. const initializeApplicationConfig = () => {
  262. if (applicationConfigInitialized) return
  263. initializePiniaStore()
  264. plugins.push(applicationConfigPlugin)
  265. if (isDesktop) {
  266. initializeTwoFactorPlugins(twoFactorConfigurationPluginLookup)
  267. }
  268. applicationConfigInitialized = true
  269. }
  270. const wrappers = new Set<[ExtendedMountingOptions<any>, ExtendedRenderResult]>()
  271. export const cleanup = () => {
  272. wrappers.forEach((wrapper) => {
  273. const [{ unmount = true }, view] = wrapper
  274. if (unmount) {
  275. view.unmount()
  276. wrappers.delete(wrapper)
  277. }
  278. })
  279. cleanupStores()
  280. }
  281. globalThis.cleanupComponents = cleanup
  282. let dialogMounted = false
  283. const mountDialog = () => {
  284. if (dialogMounted) return
  285. const Dialog = {
  286. components: { DynamicInitializer },
  287. template: '<DynamicInitializer name="dialog" />',
  288. } as any
  289. const { element } = mount(Dialog, defaultWrapperOptions)
  290. document.body.appendChild(element)
  291. dialogMounted = true
  292. }
  293. let flyoutMounted = false
  294. const mountFlyout = () => {
  295. if (flyoutMounted) return
  296. const Flyout = {
  297. components: { DynamicInitializer },
  298. template: '<DynamicInitializer name="flyout" />',
  299. } as any
  300. const { element } = mount(Flyout, defaultWrapperOptions)
  301. document.body.appendChild(element)
  302. document.body.id = 'app' // used to teleport the flyout
  303. flyoutMounted = true
  304. }
  305. setTestState({
  306. imageViewerOptions,
  307. })
  308. afterEach(() => {
  309. router?.restoreMethods()
  310. imageViewerOptions.value = {
  311. visible: false,
  312. index: 0,
  313. images: [],
  314. }
  315. })
  316. let confirmationMounted = false
  317. const mountConfirmation = () => {
  318. if (confirmationMounted) return
  319. if (!ConformationComponent) {
  320. throw new Error('ConformationComponent is not defined.')
  321. }
  322. const Confirmation = {
  323. components: { CommonConfirmation: ConformationComponent },
  324. template: '<CommonConfirmation />',
  325. } as any
  326. const { element } = mount(Confirmation, defaultWrapperOptions)
  327. document.body.appendChild(element)
  328. confirmationMounted = true
  329. }
  330. const setupVModel = <Props>(wrapperOptions: ExtendedMountingOptions<Props>) => {
  331. const vModelProps: [string, Ref][] = []
  332. const vModelOptions = Object.entries(wrapperOptions?.vModel || {})
  333. for (const [prop, propDefault] of vModelOptions) {
  334. const reactiveValue = isRef(propDefault) ? propDefault : ref(propDefault)
  335. const props = (wrapperOptions.props ?? {}) as any
  336. props[prop] = unref(propDefault)
  337. props[`onUpdate:${prop}`] = (value: unknown) => {
  338. reactiveValue.value = value
  339. }
  340. vModelProps.push([prop, reactiveValue])
  341. wrapperOptions.props = props
  342. }
  343. const startWatchingModel = (view: ExtendedRenderResult) => {
  344. if (!vModelProps.length) return
  345. watchEffect(() => {
  346. const propsValues = vModelProps.reduce(
  347. (acc, [prop, reactiveValue]) => {
  348. acc[prop] = reactiveValue.value
  349. return acc
  350. },
  351. {} as Record<string, unknown>,
  352. )
  353. view.rerender(propsValues)
  354. })
  355. }
  356. return {
  357. startWatchingModel,
  358. }
  359. }
  360. const mockProvide = (app: App, provideApi: DependencyProvideApi) => {
  361. provideApi.forEach((dependency) => {
  362. const [key, data] = dependency
  363. // App globals get reused in each test run we have to clear the provides in each test
  364. if (app._context.provides[key]) {
  365. app._context.provides[key] = data
  366. } else {
  367. app.provide(key, data)
  368. }
  369. })
  370. }
  371. const renderComponent = <Props>(
  372. component: any,
  373. wrapperOptions: ExtendedMountingOptions<Props> = {},
  374. ): ExtendedRenderResult => {
  375. initializeAppName(appName)
  376. // Store and Router needs only to be initalized once for a test suit.
  377. if (wrapperOptions.router) {
  378. initializeRouter(
  379. wrapperOptions.routerRoutes,
  380. wrapperOptions.routerBeforeGuards,
  381. )
  382. }
  383. if (wrapperOptions.store) {
  384. initializePiniaStore()
  385. }
  386. if (wrapperOptions.form) {
  387. initializeForm()
  388. }
  389. if (wrapperOptions.dialog) {
  390. mountDialog()
  391. }
  392. if (wrapperOptions.flyout) {
  393. mountFlyout()
  394. }
  395. if (wrapperOptions.confirmation) {
  396. mountConfirmation()
  397. }
  398. initializeApplicationConfig()
  399. if (wrapperOptions.visuals) {
  400. setupCommonVisualConfig(wrapperOptions.visuals)
  401. } else {
  402. initDefaultVisuals()
  403. }
  404. if (wrapperOptions.form && wrapperOptions.formField) {
  405. defaultWrapperOptions.props ||= {}
  406. // Reset the default of 20ms for testing.
  407. defaultWrapperOptions.props.delay = 0
  408. }
  409. if (wrapperOptions.plugins) {
  410. plugins.push(...wrapperOptions.plugins)
  411. delete wrapperOptions.plugins
  412. }
  413. if (wrapperOptions.provide) {
  414. plugins.push((app: App) => mockProvide(app, wrapperOptions.provide!))
  415. }
  416. const { startWatchingModel } = setupVModel(wrapperOptions)
  417. const localWrapperOptions: ExtendedMountingOptions<Props> = merge(
  418. cloneDeep(defaultWrapperOptions),
  419. wrapperOptions,
  420. )
  421. // @testing-library consoles a warning, if these options are present
  422. delete localWrapperOptions.router
  423. delete localWrapperOptions.store
  424. const view = render(component, localWrapperOptions) as ExtendedRenderResult
  425. const events = userEvent.setup({
  426. advanceTimers(delay) {
  427. if (vi.isFakeTimers()) {
  428. vi.advanceTimersByTime(delay)
  429. }
  430. },
  431. })
  432. view.events = {
  433. ...events,
  434. async debounced(cb, ms) {
  435. vi.useFakeTimers()
  436. await cb()
  437. if (ms) {
  438. vi.advanceTimersByTime(ms)
  439. } else {
  440. vi.runAllTimers()
  441. }
  442. vi.useRealTimers()
  443. await waitForNextTick()
  444. await nextTick()
  445. },
  446. }
  447. Object.assign(view, buildIconsQueries(view.baseElement as HTMLElement))
  448. Object.assign(view, buildLinksQueries(view.baseElement as HTMLElement))
  449. wrappers.add([localWrapperOptions, view])
  450. startWatchingModel(view)
  451. Object.defineProperty(view, 'router', {
  452. get() {
  453. return router
  454. },
  455. enumerable: true,
  456. configurable: true,
  457. })
  458. return view
  459. }
  460. export default renderComponent