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