forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2import { mockNuxtImport, mountSuspended } from '@nuxt/test-utils/runtime'
3import { ref, computed, readonly, nextTick } from 'vue'
4import type { VueWrapper } from '@vue/test-utils'
5import type { PendingOperation } from '../../../cli/src/types'
6import { HeaderConnectorModal } from '#components'
7
8// Mock state that will be controlled by tests
9const mockState = ref({
10 connected: false,
11 connecting: false,
12 npmUser: null as string | null,
13 avatar: null as string | null,
14 operations: [] as PendingOperation[],
15 error: null as string | null,
16 lastExecutionTime: null as number | null,
17})
18
19// Create the mock composable function
20function createMockUseConnector() {
21 return {
22 state: readonly(mockState),
23 isConnected: computed(() => mockState.value.connected),
24 isConnecting: computed(() => mockState.value.connecting),
25 npmUser: computed(() => mockState.value.npmUser),
26 avatar: computed(() => mockState.value.avatar),
27 error: computed(() => mockState.value.error),
28 lastExecutionTime: computed(() => mockState.value.lastExecutionTime),
29 operations: computed(() => mockState.value.operations),
30 pendingOperations: computed(() =>
31 mockState.value.operations.filter(op => op.status === 'pending'),
32 ),
33 approvedOperations: computed(() =>
34 mockState.value.operations.filter(op => op.status === 'approved'),
35 ),
36 completedOperations: computed(() =>
37 mockState.value.operations.filter(
38 op => op.status === 'completed' || (op.status === 'failed' && !op.result?.requiresOtp),
39 ),
40 ),
41 activeOperations: computed(() =>
42 mockState.value.operations.filter(
43 op =>
44 op.status === 'pending' ||
45 op.status === 'approved' ||
46 op.status === 'running' ||
47 (op.status === 'failed' && (op.result?.requiresOtp || op.result?.authFailure)),
48 ),
49 ),
50 hasOperations: computed(() => mockState.value.operations.length > 0),
51 hasPendingOperations: computed(() =>
52 mockState.value.operations.some(op => op.status === 'pending'),
53 ),
54 hasApprovedOperations: computed(() =>
55 mockState.value.operations.some(op => op.status === 'approved'),
56 ),
57 hasActiveOperations: computed(() =>
58 mockState.value.operations.some(
59 op =>
60 op.status === 'pending' ||
61 op.status === 'approved' ||
62 op.status === 'running' ||
63 (op.status === 'failed' && (op.result?.requiresOtp || op.result?.authFailure)),
64 ),
65 ),
66 hasCompletedOperations: computed(() =>
67 mockState.value.operations.some(
68 op =>
69 op.status === 'completed' ||
70 (op.status === 'failed' && !op.result?.requiresOtp && !op.result?.authFailure),
71 ),
72 ),
73 connect: vi.fn().mockResolvedValue(true),
74 reconnect: vi.fn().mockResolvedValue(true),
75 disconnect: vi.fn(),
76 refreshState: vi.fn().mockResolvedValue(undefined),
77 addOperation: vi.fn().mockResolvedValue(null),
78 addOperations: vi.fn().mockResolvedValue([]),
79 removeOperation: vi.fn().mockResolvedValue(true),
80 clearOperations: vi.fn().mockResolvedValue(0),
81 approveOperation: vi.fn().mockResolvedValue(true),
82 retryOperation: vi.fn().mockResolvedValue(true),
83 approveAll: vi.fn().mockResolvedValue(0),
84 executeOperations: vi.fn().mockResolvedValue({ success: true }),
85 listOrgUsers: vi.fn().mockResolvedValue(null),
86 listOrgTeams: vi.fn().mockResolvedValue(null),
87 listTeamUsers: vi.fn().mockResolvedValue(null),
88 listPackageCollaborators: vi.fn().mockResolvedValue(null),
89 listUserPackages: vi.fn().mockResolvedValue(null),
90 listUserOrgs: vi.fn().mockResolvedValue(null),
91 }
92}
93
94function resetMockState() {
95 mockState.value = {
96 connected: false,
97 connecting: false,
98 npmUser: null,
99 avatar: null,
100 operations: [],
101 error: null,
102 lastExecutionTime: null,
103 }
104 mockSettings.value.connector = {
105 autoOpenURL: false,
106 }
107}
108
109function simulateConnect() {
110 mockState.value.connected = true
111 mockState.value.npmUser = 'testuser'
112 mockState.value.avatar = 'https://example.com/avatar.png'
113}
114
115const mockSettings = ref({
116 relativeDates: false,
117 includeTypesInInstall: true,
118 accentColorId: null,
119 hidePlatformPackages: true,
120 selectedLocale: null,
121 preferredBackgroundTheme: null,
122 searchProvider: 'npm',
123 connector: {
124 autoOpenURL: false,
125 },
126 sidebar: {
127 collapsed: [],
128 },
129})
130
131mockNuxtImport('useConnector', () => {
132 return createMockUseConnector
133})
134
135mockNuxtImport('useSettings', () => {
136 return () => ({ settings: mockSettings })
137})
138
139mockNuxtImport('useSelectedPackageManager', () => {
140 return () => ref('npm')
141})
142
143vi.mock('~/utils/npm', () => ({
144 getExecuteCommand: () => 'npx npmx-connector',
145}))
146
147// Mock clipboard
148const mockWriteText = vi.fn().mockResolvedValue(undefined)
149vi.stubGlobal('navigator', {
150 ...navigator,
151 clipboard: {
152 writeText: mockWriteText,
153 readText: vi.fn().mockResolvedValue(''),
154 },
155})
156
157// Track current wrapper for cleanup
158let currentWrapper: VueWrapper | null = null
159
160/**
161 * Get the modal dialog element from the document body (where Teleport sends it).
162 */
163function getModalDialog(): HTMLDialogElement | null {
164 return document.body.querySelector('dialog#connector-modal')
165}
166
167/**
168 * Mount the component and open the dialog via showModal().
169 */
170async function mountAndOpen(state?: 'connected' | 'error') {
171 if (state === 'connected') simulateConnect()
172 if (state === 'error') {
173 mockState.value.error = 'Could not reach connector. Is it running?'
174 }
175
176 currentWrapper = await mountSuspended(HeaderConnectorModal, {
177 attachTo: document.body,
178 })
179 await nextTick()
180
181 const dialog = getModalDialog()
182 dialog?.showModal()
183 await nextTick()
184
185 return dialog
186}
187
188// Reset state before each test
189beforeEach(() => {
190 resetMockState()
191 mockWriteText.mockClear()
192})
193
194afterEach(() => {
195 vi.clearAllMocks()
196 if (currentWrapper) {
197 currentWrapper.unmount()
198 currentWrapper = null
199 }
200})
201
202describe('HeaderConnectorModal', () => {
203 describe('Connector preferences (connected)', () => {
204 it('shows auto-open URL toggle when connected', async () => {
205 const dialog = await mountAndOpen('connected')
206 const labels = Array.from(dialog?.querySelectorAll('label, span') ?? [])
207 const autoOpenLabel = labels.find(el => el.textContent?.includes('open auth page'))
208 expect(autoOpenLabel).toBeTruthy()
209 })
210
211 it('does not show a web auth toggle (web auth is now always on)', async () => {
212 const dialog = await mountAndOpen('connected')
213 const labels = Array.from(dialog?.querySelectorAll('label, span') ?? [])
214 const webAuthLabel = labels.find(el => el.textContent?.includes('web authentication'))
215 expect(webAuthLabel).toBeUndefined()
216 })
217 })
218
219 describe('Auth URL button', () => {
220 it('does not show auth URL button when no running operations have an authUrl', async () => {
221 const dialog = await mountAndOpen('connected')
222
223 const buttons = Array.from(dialog?.querySelectorAll('button') ?? [])
224 const authUrlBtn = buttons.find(b => b.textContent?.includes('web auth link'))
225 expect(authUrlBtn).toBeUndefined()
226 })
227
228 it('shows auth URL button when a running operation has an authUrl', async () => {
229 mockState.value.operations = [
230 {
231 id: '0000000000000001',
232 type: 'org:add-user',
233 params: { org: 'myorg', user: 'alice', role: 'developer' },
234 description: 'Add alice',
235 command: 'npm org set myorg alice developer',
236 status: 'running',
237 createdAt: Date.now(),
238 authUrl: 'https://www.npmjs.com/login?next=/login/cli/abc123',
239 },
240 ]
241 const dialog = await mountAndOpen('connected')
242
243 const buttons = Array.from(dialog?.querySelectorAll('button') ?? [])
244 const authUrlBtn = buttons.find(b => b.textContent?.includes('web auth link'))
245 expect(authUrlBtn).toBeTruthy()
246 })
247
248 it('opens auth URL in new tab when button is clicked', async () => {
249 const mockOpen = vi.fn()
250 vi.stubGlobal('open', mockOpen)
251
252 mockState.value.operations = [
253 {
254 id: '0000000000000001',
255 type: 'org:add-user',
256 params: { org: 'myorg', user: 'alice', role: 'developer' },
257 description: 'Add alice',
258 command: 'npm org set myorg alice developer',
259 status: 'running',
260 createdAt: Date.now(),
261 authUrl: 'https://www.npmjs.com/login?next=/login/cli/abc123',
262 },
263 ]
264 const dialog = await mountAndOpen('connected')
265
266 const buttons = Array.from(dialog?.querySelectorAll('button') ?? [])
267 const authUrlBtn = buttons.find(b =>
268 b.textContent?.includes('web auth link'),
269 ) as HTMLButtonElement
270 authUrlBtn?.click()
271 await nextTick()
272
273 expect(mockOpen).toHaveBeenCalledWith(
274 'https://www.npmjs.com/login?next=/login/cli/abc123',
275 '_blank',
276 'noopener,noreferrer',
277 )
278
279 vi.unstubAllGlobals()
280 // Re-stub navigator.clipboard which was unstubbed
281 vi.stubGlobal('navigator', {
282 ...navigator,
283 clipboard: {
284 writeText: mockWriteText,
285 readText: vi.fn().mockResolvedValue(''),
286 },
287 })
288 })
289 })
290
291 describe('Operations queue in connected state', () => {
292 it('renders OTP prompt when operations have OTP failures', async () => {
293 mockState.value.operations = [
294 {
295 id: '0000000000000001',
296 type: 'org:add-user',
297 params: { org: 'myorg', user: 'alice', role: 'developer' },
298 description: 'Add alice',
299 command: 'npm org set myorg alice developer',
300 status: 'failed',
301 createdAt: Date.now(),
302 result: { stdout: '', stderr: 'otp required', exitCode: 1, requiresOtp: true },
303 },
304 ]
305 const dialog = await mountAndOpen('connected')
306
307 // The OrgOperationsQueue child should render with the OTP alert
308 const otpAlert = dialog?.querySelector('[role="alert"]')
309 expect(otpAlert).not.toBeNull()
310 expect(dialog?.innerHTML).toContain('otp-input')
311 })
312
313 it('does not show retry with web auth when there are no auth failures', async () => {
314 mockState.value.operations = [
315 {
316 id: '0000000000000001',
317 type: 'org:add-user',
318 params: { org: 'myorg', user: 'alice', role: 'developer' },
319 description: 'Add alice',
320 command: 'npm org set myorg alice developer',
321 status: 'approved',
322 createdAt: Date.now(),
323 },
324 ]
325 const dialog = await mountAndOpen('connected')
326
327 const html = dialog?.innerHTML ?? ''
328 const hasWebAuthButton =
329 html.includes('Retry with web auth') || html.includes('retry_web_auth')
330 expect(hasWebAuthButton).toBe(false)
331 })
332
333 it('shows OTP alert section for operations with authFailure (not just requiresOtp)', async () => {
334 mockState.value.operations = [
335 {
336 id: '0000000000000001',
337 type: 'org:add-user',
338 params: { org: 'myorg', user: 'alice', role: 'developer' },
339 description: 'Add alice',
340 command: 'npm org set myorg alice developer',
341 status: 'failed',
342 createdAt: Date.now(),
343 result: { stdout: '', stderr: 'auth failed', exitCode: 1, authFailure: true },
344 },
345 ]
346 const dialog = await mountAndOpen('connected')
347
348 // The OTP/auth failures section should render for authFailure too
349 const otpAlert = dialog?.querySelector('[role="alert"]')
350 expect(otpAlert).not.toBeNull()
351 })
352 })
353
354 describe('Disconnected state', () => {
355 it('shows connection form when not connected', async () => {
356 const dialog = await mountAndOpen()
357 expect(dialog).not.toBeNull()
358
359 // Should show the form (disconnected state)
360 const form = dialog?.querySelector('form')
361 expect(form).not.toBeNull()
362
363 // Should show token input
364 const tokenInput = dialog?.querySelector('input[name="connector-token"]')
365 expect(tokenInput).not.toBeNull()
366
367 // Should show connect button
368 const connectButton = dialog?.querySelector('button[type="submit"]')
369 expect(connectButton).not.toBeNull()
370 })
371
372 it('shows the CLI command to run', async () => {
373 const dialog = await mountAndOpen()
374 // The command is now "pnpm npmx-connector"
375 expect(dialog?.textContent).toContain('npmx-connector')
376 })
377
378 it('has a copy button for the command', async () => {
379 const dialog = await mountAndOpen()
380 // The copy button is inside the command block (dir="ltr" div)
381 const commandBlock = dialog?.querySelector('div[dir="ltr"]')
382 const copyBtn = commandBlock?.querySelector('button') as HTMLButtonElement
383 expect(copyBtn).toBeTruthy()
384 // The button should have a copy-related aria-label
385 expect(copyBtn?.getAttribute('aria-label')).toBeTruthy()
386 })
387
388 it('disables connect button when token is empty', async () => {
389 const dialog = await mountAndOpen()
390 const connectButton = dialog?.querySelector('button[type="submit"]') as HTMLButtonElement
391 expect(connectButton?.disabled).toBe(true)
392 })
393
394 it('enables connect button when token is entered', async () => {
395 const dialog = await mountAndOpen()
396 const tokenInput = dialog?.querySelector('input[name="connector-token"]') as HTMLInputElement
397 expect(tokenInput).not.toBeNull()
398
399 // Set value and dispatch input event to trigger v-model
400 tokenInput.value = 'my-test-token'
401 tokenInput.dispatchEvent(new Event('input', { bubbles: true }))
402 await nextTick()
403
404 const connectButton = dialog?.querySelector('button[type="submit"]') as HTMLButtonElement
405 expect(connectButton?.disabled).toBe(false)
406 })
407
408 it('shows error message when connection fails', async () => {
409 const dialog = await mountAndOpen('error')
410 // Error needs hasAttemptedConnect=true to show. Simulate a connect attempt first.
411 const tokenInput = dialog?.querySelector('input[name="connector-token"]') as HTMLInputElement
412 tokenInput.value = 'bad-token'
413 tokenInput.dispatchEvent(new Event('input', { bubbles: true }))
414 await nextTick()
415
416 const form = dialog?.querySelector('form')
417 form?.dispatchEvent(new Event('submit', { bubbles: true }))
418 await nextTick()
419
420 const alerts = dialog?.querySelectorAll('[role="alert"]')
421 const errorAlert = Array.from(alerts || []).find(el =>
422 el.textContent?.includes('Could not reach connector'),
423 )
424 expect(errorAlert).not.toBeUndefined()
425 })
426 })
427
428 describe('Connected state', () => {
429 it('shows connected status', async () => {
430 const dialog = await mountAndOpen('connected')
431 expect(dialog?.textContent).toContain('Connected')
432 })
433
434 it('shows logged in username', async () => {
435 const dialog = await mountAndOpen('connected')
436 expect(dialog?.textContent).toContain('testuser')
437 })
438
439 it('shows disconnect button', async () => {
440 const dialog = await mountAndOpen('connected')
441 const buttons = dialog?.querySelectorAll('button')
442 const disconnectBtn = Array.from(buttons || []).find(b =>
443 b.textContent?.toLowerCase().includes('disconnect'),
444 )
445 expect(disconnectBtn).not.toBeUndefined()
446 })
447
448 it('hides connection form when connected', async () => {
449 const dialog = await mountAndOpen('connected')
450 const form = dialog?.querySelector('form')
451 expect(form).toBeNull()
452 })
453 })
454
455 describe('Modal behavior', () => {
456 it('closes modal when close button is clicked', async () => {
457 const dialog = await mountAndOpen()
458
459 // Find the close button (ButtonBase with close icon) in the dialog header
460 const closeBtn = Array.from(dialog?.querySelectorAll('button') ?? []).find(
461 b =>
462 b.querySelector('[class*="close"]') ||
463 b.getAttribute('aria-label')?.toLowerCase().includes('close'),
464 ) as HTMLButtonElement
465 expect(closeBtn).toBeTruthy()
466
467 closeBtn?.click()
468 await nextTick()
469
470 // Dialog should be closed (open attribute removed)
471 expect(dialog?.open).toBe(false)
472 })
473
474 it('does not render dialog content when not opened', async () => {
475 currentWrapper = await mountSuspended(HeaderConnectorModal, {
476 attachTo: document.body,
477 })
478 await nextTick()
479
480 const dialog = getModalDialog()
481 // Dialog exists in DOM but should not be open
482 expect(dialog?.open).toBeFalsy()
483 })
484 })
485})