[READ-ONLY] a fast, modern browser for the npm registry
at main 485 lines 17 kB view raw
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})