index.test.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407
  1. import { render, screen, waitFor } from '@testing-library/react'
  2. import { LOCAL_STORAGE_KEYS } from 'common'
  3. import type { ReactNode } from 'react'
  4. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  5. import { MobileSheetProvider } from '../Navigation/NavigationBar/MobileSheetContext'
  6. import { ProjectLayout } from './index'
  7. import { STUDIO_PAGE_TITLE_SEPARATOR } from '@/lib/page-title'
  8. const { mockRouter, mockSetSelectedDatabaseId, mockSetMobileMenuOpen } = vi.hoisted(() => ({
  9. mockRouter: {
  10. pathname: '/project/[ref]/observability/query-performance',
  11. asPath: '/project/default/observability/query-performance',
  12. push: vi.fn(),
  13. replace: vi.fn(),
  14. },
  15. mockSetSelectedDatabaseId: vi.fn(),
  16. mockSetMobileMenuOpen: vi.fn(),
  17. }))
  18. const {
  19. mockAddBanner,
  20. mockDismissBanner,
  21. mockProjectState,
  22. mockResourceWarningsState,
  23. mockBannerDismissedState,
  24. mockUseLocalStorageQuery,
  25. } = vi.hoisted(() => ({
  26. mockAddBanner: vi.fn(),
  27. mockDismissBanner: vi.fn(),
  28. mockProjectState: {
  29. current: {
  30. ref: 'default',
  31. name: 'Project 1',
  32. status: 'ACTIVE_HEALTHY',
  33. postgrestStatus: 'ONLINE',
  34. infra_compute_size: undefined as string | undefined,
  35. integration_source: null as string | null,
  36. },
  37. },
  38. mockResourceWarningsState: { current: undefined as any[] | undefined },
  39. mockBannerDismissedState: { current: false },
  40. mockUseLocalStorageQuery: vi.fn(),
  41. }))
  42. vi.mock('next/router', () => ({
  43. useRouter: () => mockRouter,
  44. }))
  45. vi.mock('next/head', async () => {
  46. const React = await import('react')
  47. const Head = ({ children }: { children?: ReactNode }) => {
  48. React.useEffect(() => {
  49. const titleElement = React.Children.toArray(children).find(
  50. (child) => React.isValidElement(child) && child.type === 'title'
  51. )
  52. if (!React.isValidElement<{ children: ReactNode }>(titleElement)) return
  53. const titleText = React.Children.toArray(titleElement.props.children).join('')
  54. document.title = titleText
  55. }, [children])
  56. return null
  57. }
  58. return { default: Head }
  59. })
  60. vi.mock('common', () => ({
  61. useParams: () => ({ ref: 'default' }),
  62. mergeRefs:
  63. (..._refs: any[]) =>
  64. (_value: unknown) => {},
  65. IS_PLATFORM: false,
  66. LOCAL_STORAGE_KEYS: {
  67. FREE_MICRO_UPGRADE_BANNER_DISMISSED: (ref: string) =>
  68. `free-micro-upgrade-banner-dismissed-${ref}`,
  69. PROJECT_INTEGRATION_BANNER_DISMISSED: (ref: string, integrationSource: string) =>
  70. `project-integration-banner-dismissed-${ref}-${integrationSource}`,
  71. },
  72. isFeatureEnabled: () => false,
  73. }))
  74. vi.mock('framer-motion', () => ({
  75. AnimatePresence: ({ children }: { children: ReactNode }) => <>{children}</>,
  76. motion: {
  77. div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
  78. create: (Component: any) => Component,
  79. },
  80. }))
  81. vi.mock('ui', () => ({
  82. cn: (...classes: Array<string | false | null | undefined>) => classes.filter(Boolean).join(' '),
  83. Alert: ({ children, ...props }: any) => <div {...props}>{children}</div>,
  84. AlertDescription: ({ children, ...props }: any) => <div {...props}>{children}</div>,
  85. AlertTitle: ({ children, ...props }: any) => <div {...props}>{children}</div>,
  86. CommandInput: { displayName: 'CommandInput' },
  87. Command: { displayName: 'Command' },
  88. CommandGroup: { displayName: 'CommandGroup' },
  89. CommandItem: { displayName: 'CommandItem' },
  90. CommandList: { displayName: 'CommandList' },
  91. LogoLoader: () => <div data-testid="logo-loader" />,
  92. ResizableHandle: (props: any) => <div {...props} />,
  93. ResizablePanel: ({ children, ...props }: any) => <div {...props}>{children}</div>,
  94. ResizablePanelGroup: ({ children, ...props }: any) => <div {...props}>{children}</div>,
  95. Sidebar: ({ children, ...props }: any) => <div {...props}>{children}</div>,
  96. SidebarContent: ({ children, ...props }: any) => <div {...props}>{children}</div>,
  97. SidebarFooter: ({ children, ...props }: any) => <div {...props}>{children}</div>,
  98. SidebarGroup: ({ children, ...props }: any) => <div {...props}>{children}</div>,
  99. SidebarMenu: ({ children, ...props }: any) => <div {...props}>{children}</div>,
  100. SidebarMenuButton: (props: any) => <div {...props} />,
  101. SidebarMenuItem: (props: any) => <div {...props} />,
  102. useIsMobile: () => false,
  103. usePanelRef: () => undefined,
  104. useSidebar: () => ({ setOpen: vi.fn() }),
  105. }))
  106. vi.mock('ui-patterns/MobileSheetNav/MobileSheetNav', () => ({
  107. default: ({ children }: { children: ReactNode }) => <>{children}</>,
  108. }))
  109. vi.mock('../editors/EditorsLayout.hooks', () => ({
  110. useEditorType: () => undefined,
  111. }))
  112. vi.mock('../MainScrollContainerContext', () => ({
  113. useSetMainScrollContainer: () => () => {},
  114. }))
  115. vi.mock('./BuildingState', () => ({ default: () => null }))
  116. vi.mock('./ConnectingState', () => ({ default: () => null }))
  117. vi.mock('./LoadingState', () => ({ LoadingState: () => null }))
  118. vi.mock('./PausedState/ProjectPausedState', () => ({ ProjectPausedState: () => null }))
  119. vi.mock('./PauseFailedState', () => ({ PauseFailedState: () => null }))
  120. vi.mock('./PausingState', () => ({ PausingState: () => null }))
  121. vi.mock('./ProductMenuBar', () => ({
  122. default: ({ children }: { children: ReactNode }) => <>{children}</>,
  123. }))
  124. vi.mock('./ResizingState', () => ({ ResizingState: () => null }))
  125. vi.mock('./RestartingState', () => ({ default: () => null }))
  126. vi.mock('./RestoreFailedState', () => ({ RestoreFailedState: () => null }))
  127. vi.mock('./RestoringState', () => ({ RestoringState: () => null }))
  128. vi.mock('./UpgradingState', () => ({ UpgradingState: () => null }))
  129. vi.mock('@/components/interfaces/BranchManagement/CreateBranchModal', () => ({
  130. CreateBranchModal: () => null,
  131. }))
  132. vi.mock('@/components/interfaces/ProjectAPIDocs/ProjectAPIDocs', () => ({
  133. ProjectAPIDocs: () => null,
  134. }))
  135. vi.mock('@/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner', () => ({
  136. ResourceExhaustionWarningBanner: () => null,
  137. }))
  138. vi.mock('@/components/ui/ButtonTooltip', () => ({
  139. ButtonTooltip: ({ children, ...props }: any) => <button {...props}>{children}</button>,
  140. }))
  141. vi.mock('@/components/ui/PartnerIcon', () => ({
  142. default: () => <div data-testid="partner-icon" />,
  143. }))
  144. vi.mock('@/hooks/custom-content/useCustomContent', () => ({
  145. useCustomContent: () => ({ appTitle: 'Briven' }),
  146. }))
  147. vi.mock('@/hooks/misc/useLocalStorage', () => ({
  148. useLocalStorageQuery: (...args: unknown[]) => mockUseLocalStorageQuery(...args),
  149. }))
  150. vi.mock('@/components/ui/BannerStack/BannerStackProvider', () => ({
  151. BANNER_ID: { FREE_MICRO_UPGRADE: 'free-micro-upgrade-banner' },
  152. useBannerStack: () => ({
  153. addBanner: mockAddBanner,
  154. dismissBanner: mockDismissBanner,
  155. banners: [],
  156. }),
  157. }))
  158. vi.mock('@/components/ui/BannerStack/Banners/BannerFreeMicroUpgrade', () => ({
  159. BannerFreeMicroUpgrade: () => null,
  160. }))
  161. vi.mock('@/data/usage/resource-warnings-query', () => ({
  162. useResourceWarningsQuery: () => ({ data: mockResourceWarningsState.current }),
  163. }))
  164. vi.mock('@/hooks/misc/useSelectedOrganization', () => ({
  165. useSelectedOrganizationQuery: () => ({
  166. data: { name: 'Organization 1', slug: 'org-1' },
  167. }),
  168. }))
  169. vi.mock('@/hooks/misc/useSelectedProject', () => ({
  170. useSelectedProjectQuery: () => ({ data: mockProjectState.current }),
  171. }))
  172. vi.mock('@/hooks/misc/withAuth', () => ({
  173. withAuth: (Component: any) => Component,
  174. }))
  175. vi.mock('@/hooks/ui/useFlag', () => ({
  176. usePHFlag: () => undefined,
  177. }))
  178. vi.mock('@/state/app-state', () => ({
  179. useAppStateSnapshot: () => ({
  180. mobileMenuOpen: false,
  181. showSidebar: false,
  182. setMobileMenuOpen: mockSetMobileMenuOpen,
  183. }),
  184. }))
  185. vi.mock('@/state/database-selector', () => ({
  186. useDatabaseSelectorStateSnapshot: () => ({
  187. setSelectedDatabaseId: mockSetSelectedDatabaseId,
  188. }),
  189. }))
  190. const renderLayout = () =>
  191. render(
  192. <MobileSheetProvider>
  193. <ProjectLayout product="Database" isBlocking={false}>
  194. <div />
  195. </ProjectLayout>
  196. </MobileSheetProvider>
  197. )
  198. describe('ProjectLayout title', () => {
  199. beforeEach(() => {
  200. mockRouter.pathname = '/project/[ref]/observability/query-performance'
  201. mockRouter.asPath = '/project/default/observability/query-performance'
  202. document.title = ''
  203. mockProjectState.current = {
  204. ref: 'default',
  205. name: 'Project 1',
  206. status: 'ACTIVE_HEALTHY',
  207. postgrestStatus: 'ONLINE',
  208. infra_compute_size: undefined,
  209. integration_source: null,
  210. }
  211. mockBannerDismissedState.current = false
  212. mockUseLocalStorageQuery.mockImplementation(() => [mockBannerDismissedState.current, vi.fn()])
  213. })
  214. afterEach(() => {
  215. vi.clearAllMocks()
  216. document.title = ''
  217. })
  218. it('sets a composed document title and deduplicates identical section/surface labels', async () => {
  219. render(
  220. <MobileSheetProvider>
  221. <ProjectLayout browserTitle={{ section: 'Settings' }} product="Settings" isBlocking={false}>
  222. <div>Page Content</div>
  223. </ProjectLayout>
  224. </MobileSheetProvider>
  225. )
  226. await waitFor(() => {
  227. expect(document.title).toBe(
  228. ['Settings', 'Project 1', 'Organization 1', 'Briven'].join(STUDIO_PAGE_TITLE_SEPARATOR)
  229. )
  230. })
  231. })
  232. it('prefers entity-first browserTitle metadata when provided', async () => {
  233. render(
  234. <MobileSheetProvider>
  235. <ProjectLayout
  236. product="Database"
  237. browserTitle={{ entity: 'users', section: 'Tables' }}
  238. isBlocking={false}
  239. >
  240. <div>Page Content</div>
  241. </ProjectLayout>
  242. </MobileSheetProvider>
  243. )
  244. await waitFor(() => {
  245. expect(document.title).toBe(
  246. ['users', 'Tables', 'Database', 'Project 1', 'Organization 1', 'Briven'].join(
  247. STUDIO_PAGE_TITLE_SEPARATOR
  248. )
  249. )
  250. })
  251. })
  252. it('renders the Stripe project banner across project surfaces when the selected project is Stripe-connected', () => {
  253. mockProjectState.current = {
  254. ...mockProjectState.current,
  255. integration_source: 'stripe_projects',
  256. }
  257. renderLayout()
  258. expect(screen.getByText('This project is connected to Stripe')).toBeTruthy()
  259. expect(
  260. screen.getByText('Changes made here may affect your connected Stripe project.')
  261. ).toBeTruthy()
  262. expect(screen.getByTestId('partner-icon')).toBeTruthy()
  263. })
  264. it('uses a project-specific dismiss key for the Stripe project banner', () => {
  265. mockProjectState.current = {
  266. ...mockProjectState.current,
  267. integration_source: 'stripe_projects',
  268. }
  269. renderLayout()
  270. expect(mockUseLocalStorageQuery).toHaveBeenCalledWith(
  271. LOCAL_STORAGE_KEYS.PROJECT_INTEGRATION_BANNER_DISMISSED('default', 'stripe_projects'),
  272. false
  273. )
  274. })
  275. })
  276. describe('FREE_MICRO_UPGRADE banner', () => {
  277. beforeEach(() => {
  278. mockRouter.pathname = '/project/[ref]'
  279. mockRouter.asPath = '/project/default'
  280. mockProjectState.current = {
  281. ref: 'default',
  282. name: 'Project 1',
  283. status: 'ACTIVE_HEALTHY',
  284. postgrestStatus: 'ONLINE',
  285. infra_compute_size: 'nano',
  286. integration_source: null,
  287. }
  288. mockResourceWarningsState.current = [
  289. {
  290. project: 'default',
  291. cpu_exhaustion: true,
  292. memory_and_swap_exhaustion: false,
  293. disk_space_exhaustion: false,
  294. },
  295. ]
  296. mockBannerDismissedState.current = false
  297. })
  298. afterEach(() => {
  299. vi.clearAllMocks()
  300. mockRouter.pathname = '/project/[ref]/observability/query-performance'
  301. mockRouter.asPath = '/project/default/observability/query-performance'
  302. mockProjectState.current = {
  303. ref: 'default',
  304. name: 'Project 1',
  305. status: 'ACTIVE_HEALTHY',
  306. postgrestStatus: 'ONLINE',
  307. infra_compute_size: undefined,
  308. integration_source: null,
  309. }
  310. mockResourceWarningsState.current = undefined
  311. mockBannerDismissedState.current = false
  312. })
  313. it('calls addBanner when project is nano and compute is near exhaustion', async () => {
  314. renderLayout()
  315. await waitFor(() => {
  316. expect(mockAddBanner).toHaveBeenCalledWith(
  317. expect.objectContaining({ id: 'free-micro-upgrade-banner' })
  318. )
  319. })
  320. })
  321. it('calls dismissBanner when banner was previously dismissed', async () => {
  322. mockBannerDismissedState.current = true
  323. renderLayout()
  324. await waitFor(() => {
  325. expect(mockDismissBanner).toHaveBeenCalledWith('free-micro-upgrade-banner')
  326. })
  327. expect(mockAddBanner).not.toHaveBeenCalled()
  328. })
  329. it('calls dismissBanner when compute warnings are cleared', async () => {
  330. mockResourceWarningsState.current = [
  331. {
  332. project: 'default',
  333. cpu_exhaustion: false,
  334. memory_and_swap_exhaustion: false,
  335. disk_space_exhaustion: false,
  336. },
  337. ]
  338. renderLayout()
  339. await waitFor(() => {
  340. expect(mockDismissBanner).toHaveBeenCalledWith('free-micro-upgrade-banner')
  341. })
  342. expect(mockAddBanner).not.toHaveBeenCalled()
  343. })
  344. it('calls dismissBanner when project is not nano compute', async () => {
  345. mockProjectState.current = { ...mockProjectState.current, infra_compute_size: 'micro' }
  346. renderLayout()
  347. await waitFor(() => {
  348. expect(mockDismissBanner).toHaveBeenCalledWith('free-micro-upgrade-banner')
  349. })
  350. expect(mockAddBanner).not.toHaveBeenCalled()
  351. })
  352. })