renderComponent.ts 15 KB

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