cli-login.test.tsx 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. import { screen, waitFor } from '@testing-library/react'
  2. import userEvent from '@testing-library/user-event'
  3. import { beforeEach, describe, expect, test, vi } from 'vitest'
  4. import type { ProfileContextType } from '@/lib/profile'
  5. import { CliLoginScreen } from '@/pages/cli/login'
  6. import { customRender } from '@/tests/lib/custom-render'
  7. const { createCliLoginSessionMock } = vi.hoisted(() => ({
  8. createCliLoginSessionMock: vi.fn(),
  9. }))
  10. vi.mock('@/data/cli/login', () => ({
  11. createCliLoginSession: createCliLoginSessionMock,
  12. }))
  13. const DEFAULT_PROFILE_CONTEXT: ProfileContextType = {
  14. profile: {
  15. id: 1,
  16. auth0_id: 'auth0|test',
  17. gotrue_id: 'gotrue-test',
  18. username: 'testuser',
  19. primary_email: 'test@example.com',
  20. first_name: null,
  21. last_name: null,
  22. mobile: null,
  23. is_alpha_user: false,
  24. is_sso_user: false,
  25. disabled_features: [],
  26. free_project_limit: null,
  27. },
  28. error: null,
  29. isLoading: false,
  30. isError: false,
  31. isSuccess: true,
  32. }
  33. function renderScreen(props: Partial<Parameters<typeof CliLoginScreen>[0]> = {}) {
  34. const navigate = vi.fn()
  35. const result = customRender(
  36. <CliLoginScreen
  37. isLoggedIn
  38. routerReady
  39. sessionId="session-test"
  40. publicKey="public-key-test"
  41. tokenName="local-dev"
  42. navigate={navigate}
  43. {...props}
  44. />,
  45. { profileContext: DEFAULT_PROFILE_CONTEXT }
  46. )
  47. return { ...result, navigate }
  48. }
  49. describe('CliLoginScreen', () => {
  50. beforeEach(() => {
  51. vi.clearAllMocks()
  52. })
  53. test('creates a session and routes to the device code', async () => {
  54. createCliLoginSessionMock.mockResolvedValue({ nonce: 'ABCDEFGH12345678' })
  55. const { navigate } = renderScreen()
  56. await waitFor(() => {
  57. expect(createCliLoginSessionMock).toHaveBeenCalledWith(
  58. 'session-test',
  59. 'public-key-test',
  60. 'local-dev'
  61. )
  62. })
  63. await waitFor(() => {
  64. expect(navigate).toHaveBeenCalledWith('/cli/login?device_code=ABCDEFGH')
  65. })
  66. })
  67. test('renders ready state with verification code and copy control', async () => {
  68. const user = userEvent.setup()
  69. const writeText = vi.fn()
  70. vi.spyOn(window.document, 'hasFocus').mockReturnValue(true)
  71. Object.defineProperty(navigator, 'clipboard', {
  72. configurable: true,
  73. value: { writeText },
  74. })
  75. const { container } = renderScreen({ deviceCode: 'ZXCV9876' })
  76. expect(container).toHaveTextContent('ZXCV9876')
  77. await user.click(screen.getByRole('button', { name: 'Copy code' }))
  78. expect(await screen.findByRole('button', { name: 'Copied' })).toBeInTheDocument()
  79. expect(writeText).toHaveBeenCalledWith('ZXCV9876')
  80. })
  81. test('copies selected verification code as a single string', () => {
  82. renderScreen({ deviceCode: 'ZXCV9876' })
  83. const clipboardData = { setData: vi.fn() }
  84. const code = screen.getByLabelText('Verification code ZXCV9876')
  85. const copyEvent = new Event('copy', { bubbles: true })
  86. Object.defineProperty(copyEvent, 'clipboardData', {
  87. value: clipboardData,
  88. })
  89. code.dispatchEvent(copyEvent)
  90. expect(clipboardData.setData).toHaveBeenCalledWith('text/plain', 'ZXCV9876')
  91. })
  92. test('renders missing-params state without redirecting away', () => {
  93. renderScreen({ sessionId: undefined })
  94. expect(screen.getByText('Missing sign-in parameters')).toBeInTheDocument()
  95. expect(screen.getByText(/session_id/)).toBeInTheDocument()
  96. })
  97. test('renders creation error state in the card', async () => {
  98. createCliLoginSessionMock.mockRejectedValue(new Error('Session expired'))
  99. renderScreen()
  100. expect(await screen.findByText('Unable to create CLI sign-in')).toBeInTheDocument()
  101. expect(screen.getByText(/Session expired/)).toBeInTheDocument()
  102. })
  103. test('surfaces error messages from non-Error rejection shapes (openapi-fetch)', async () => {
  104. createCliLoginSessionMock.mockRejectedValue({
  105. message:
  106. 'User can have up to 20 personal access tokens. Please remove the excess tokens to create new ones.',
  107. statusCode: 403,
  108. })
  109. renderScreen()
  110. expect(await screen.findByText('Unable to create CLI sign-in')).toBeInTheDocument()
  111. expect(screen.getByText(/User can have up to 20 personal access tokens/)).toBeInTheDocument()
  112. expect(screen.queryByText(/Unknown error/)).not.toBeInTheDocument()
  113. })
  114. test('POSTs createCliLoginSession exactly once even when parent re-renders', async () => {
  115. createCliLoginSessionMock.mockResolvedValue({ nonce: 'ABCDEFGH12345678' })
  116. const { rerender } = renderScreen()
  117. await waitFor(() => {
  118. expect(createCliLoginSessionMock).toHaveBeenCalledTimes(1)
  119. })
  120. // Re-render with a brand-new `navigate` prop (mirrors the production
  121. // bug where the parent recreated the closure on every render).
  122. for (let i = 0; i < 5; i++) {
  123. rerender(
  124. <CliLoginScreen
  125. isLoggedIn
  126. routerReady
  127. sessionId="session-test"
  128. publicKey="public-key-test"
  129. tokenName="local-dev"
  130. navigate={vi.fn()}
  131. />
  132. )
  133. }
  134. expect(createCliLoginSessionMock).toHaveBeenCalledTimes(1)
  135. })
  136. })