Skip to content

Commit 37a03a6

Browse files
committed
test: add some additional unit + component tests
1 parent d9e8896 commit 37a03a6

File tree

5 files changed

+447
-12
lines changed

5 files changed

+447
-12
lines changed

cli/src/mock-state.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,7 @@ export class MockConnectorStateManager {
283283
exitCode: configuredResult.exitCode ?? 1,
284284
requiresOtp: configuredResult.requiresOtp,
285285
authFailure: configuredResult.authFailure,
286+
urls: configuredResult.urls,
286287
}
287288
op.result = result
288289
op.status = result.exitCode === 0 ? 'completed' : 'failed'

test/nuxt/components/HeaderConnectorModal.spec.ts

Lines changed: 186 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2-
import { mountSuspended } from '@nuxt/test-utils/runtime'
2+
import { mockNuxtImport, mountSuspended } from '@nuxt/test-utils/runtime'
33
import { ref, computed, readonly, nextTick } from 'vue'
44
import type { VueWrapper } from '@vue/test-utils'
55
import type { PendingOperation } from '../../../cli/src/types'
@@ -110,11 +110,6 @@ function simulateConnect() {
110110
mockState.value.avatar = 'https://example.com/avatar.png'
111111
}
112112

113-
// Mock the composables at module level (vi.mock is hoisted)
114-
vi.mock('~/composables/useConnector', () => ({
115-
useConnector: createMockUseConnector,
116-
}))
117-
118113
const mockSettings = ref({
119114
relativeDates: false,
120115
includeTypesInInstall: true,
@@ -131,13 +126,17 @@ const mockSettings = ref({
131126
},
132127
})
133128

134-
vi.mock('~/composables/useSettings', () => ({
135-
useSettings: () => ({ settings: mockSettings }),
136-
}))
129+
mockNuxtImport('useConnector', () => {
130+
return createMockUseConnector
131+
})
137132

138-
vi.mock('~/composables/useSelectedPackageManager', () => ({
139-
useSelectedPackageManager: () => ref('npm'),
140-
}))
133+
mockNuxtImport('useSettings', () => {
134+
return () => ({ settings: mockSettings })
135+
})
136+
137+
mockNuxtImport('useSelectedPackageManager', () => {
138+
return () => ref('npm')
139+
})
141140

142141
vi.mock('~/utils/npm', () => ({
143142
getExecuteCommand: () => 'npx npmx-connector',
@@ -199,6 +198,181 @@ afterEach(() => {
199198
})
200199

201200
describe('HeaderConnectorModal', () => {
201+
describe('Web auth settings (connected)', () => {
202+
it('shows web auth toggle when connected', async () => {
203+
const dialog = await mountAndOpen('connected')
204+
const labels = Array.from(dialog?.querySelectorAll('label, span') ?? [])
205+
const webAuthLabel = labels.find(el => el.textContent?.includes('web authentication'))
206+
expect(webAuthLabel).toBeTruthy()
207+
})
208+
209+
it('shows auto-open URL toggle when connected', async () => {
210+
const dialog = await mountAndOpen('connected')
211+
const labels = Array.from(dialog?.querySelectorAll('label, span') ?? [])
212+
const autoOpenLabel = labels.find(el => el.textContent?.includes('open auth page'))
213+
expect(autoOpenLabel).toBeTruthy()
214+
})
215+
216+
it('auto-open URL toggle is disabled when webAuth is off', async () => {
217+
mockSettings.value.connector.webAuth = false
218+
const dialog = await mountAndOpen('connected')
219+
220+
// Find the auto-open toggle container - it should have opacity-50 class
221+
const toggleContainers = Array.from(dialog?.querySelectorAll('[class*="opacity-50"]') ?? [])
222+
expect(toggleContainers.length).toBeGreaterThan(0)
223+
})
224+
225+
it('auto-open URL toggle is not disabled when webAuth is on', async () => {
226+
mockSettings.value.connector.webAuth = true
227+
const dialog = await mountAndOpen('connected')
228+
229+
// When webAuth is ON, the auto-open toggle should not have opacity-50
230+
// Verify by checking that we can find the toggle with the "open auth page" label
231+
// and it does NOT have opacity-50 in its parent
232+
const autoOpenLabels = Array.from(dialog?.querySelectorAll('*') ?? []).filter(el =>
233+
el.textContent?.includes('open auth page'),
234+
)
235+
expect(autoOpenLabels.length).toBeGreaterThan(0)
236+
})
237+
})
238+
239+
describe('Web auth settings (disconnected advanced)', () => {
240+
it('shows web auth toggles in advanced details section', async () => {
241+
const dialog = await mountAndOpen()
242+
243+
// Open the advanced details section
244+
const details = dialog?.querySelector('details')
245+
expect(details).not.toBeNull()
246+
247+
// Programmatically open it
248+
details?.setAttribute('open', '')
249+
await nextTick()
250+
251+
const labels = Array.from(details?.querySelectorAll('label, span') ?? [])
252+
const webAuthLabel = labels.find(el => el.textContent?.includes('web authentication'))
253+
const autoOpenLabel = labels.find(el => el.textContent?.includes('open auth page'))
254+
expect(webAuthLabel).toBeTruthy()
255+
expect(autoOpenLabel).toBeTruthy()
256+
})
257+
})
258+
259+
describe('Auth URL button', () => {
260+
it('does not show auth URL button when no running operations have an authUrl', async () => {
261+
const dialog = await mountAndOpen('connected')
262+
263+
const buttons = Array.from(dialog?.querySelectorAll('button') ?? [])
264+
const authUrlBtn = buttons.find(b => b.textContent?.includes('web auth link'))
265+
expect(authUrlBtn).toBeUndefined()
266+
})
267+
268+
it('shows auth URL button when a running operation has an authUrl', async () => {
269+
mockState.value.operations = [
270+
{
271+
id: '0000000000000001',
272+
type: 'org:add-user',
273+
params: { org: 'myorg', user: 'alice', role: 'developer' },
274+
description: 'Add alice',
275+
command: 'npm org set myorg alice developer',
276+
status: 'running',
277+
createdAt: Date.now(),
278+
authUrl: 'https://www.npmjs.com/login?next=/login/cli/abc123',
279+
},
280+
]
281+
const dialog = await mountAndOpen('connected')
282+
283+
const buttons = Array.from(dialog?.querySelectorAll('button') ?? [])
284+
const authUrlBtn = buttons.find(b => b.textContent?.includes('web auth link'))
285+
expect(authUrlBtn).toBeTruthy()
286+
})
287+
288+
it('opens auth URL in new tab when button is clicked', async () => {
289+
const mockOpen = vi.fn()
290+
vi.stubGlobal('open', mockOpen)
291+
292+
mockState.value.operations = [
293+
{
294+
id: '0000000000000001',
295+
type: 'org:add-user',
296+
params: { org: 'myorg', user: 'alice', role: 'developer' },
297+
description: 'Add alice',
298+
command: 'npm org set myorg alice developer',
299+
status: 'running',
300+
createdAt: Date.now(),
301+
authUrl: 'https://www.npmjs.com/login?next=/login/cli/abc123',
302+
},
303+
]
304+
const dialog = await mountAndOpen('connected')
305+
306+
const buttons = Array.from(dialog?.querySelectorAll('button') ?? [])
307+
const authUrlBtn = buttons.find(b =>
308+
b.textContent?.includes('web auth link'),
309+
) as HTMLButtonElement
310+
authUrlBtn?.click()
311+
await nextTick()
312+
313+
expect(mockOpen).toHaveBeenCalledWith(
314+
'https://www.npmjs.com/login?next=/login/cli/abc123',
315+
'_blank',
316+
'noopener,noreferrer',
317+
)
318+
319+
vi.unstubAllGlobals()
320+
// Re-stub navigator.clipboard which was unstubbed
321+
vi.stubGlobal('navigator', {
322+
...navigator,
323+
clipboard: {
324+
writeText: mockWriteText,
325+
readText: vi.fn().mockResolvedValue(''),
326+
},
327+
})
328+
})
329+
})
330+
331+
describe('Operations queue in connected state', () => {
332+
it('renders OTP prompt when operations have OTP failures', async () => {
333+
mockState.value.operations = [
334+
{
335+
id: '0000000000000001',
336+
type: 'org:add-user',
337+
params: { org: 'myorg', user: 'alice', role: 'developer' },
338+
description: 'Add alice',
339+
command: 'npm org set myorg alice developer',
340+
status: 'failed',
341+
createdAt: Date.now(),
342+
result: { stdout: '', stderr: 'otp required', exitCode: 1, requiresOtp: true },
343+
},
344+
]
345+
const dialog = await mountAndOpen('connected')
346+
347+
// The OrgOperationsQueue child should render with the OTP alert
348+
const otpAlert = dialog?.querySelector('[role="alert"]')
349+
expect(otpAlert).not.toBeNull()
350+
expect(dialog?.innerHTML).toContain('otp-input')
351+
})
352+
353+
it('does not show retry with web auth button when webAuth setting is off', async () => {
354+
mockSettings.value.connector.webAuth = false
355+
mockState.value.operations = [
356+
{
357+
id: '0000000000000001',
358+
type: 'org:add-user',
359+
params: { org: 'myorg', user: 'alice', role: 'developer' },
360+
description: 'Add alice',
361+
command: 'npm org set myorg alice developer',
362+
status: 'failed',
363+
createdAt: Date.now(),
364+
result: { stdout: '', stderr: 'otp required', exitCode: 1, requiresOtp: true },
365+
},
366+
]
367+
const dialog = await mountAndOpen('connected')
368+
369+
const html = dialog?.innerHTML ?? ''
370+
const hasWebAuthButton =
371+
html.includes('Retry with web auth') || html.includes('retry_web_auth')
372+
expect(hasWebAuthButton).toBe(false)
373+
})
374+
})
375+
202376
describe('Disconnected state', () => {
203377
it('shows connection form when not connected', async () => {
204378
const dialog = await mountAndOpen()

test/unit/cli/mock-state.spec.ts

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { describe, expect, it, beforeEach } from 'vitest'
2+
import { MockConnectorStateManager, createMockConnectorState } from '../../../cli/src/mock-state.ts'
3+
4+
function createManager() {
5+
const data = createMockConnectorState({ token: 'test-token', npmUser: 'testuser' })
6+
return new MockConnectorStateManager(data)
7+
}
8+
9+
describe('MockConnectorStateManager: executeOperations', () => {
10+
let manager: MockConnectorStateManager
11+
12+
beforeEach(() => {
13+
manager = createManager()
14+
manager.connect('test-token')
15+
})
16+
17+
it('returns authFailure when a configured result has authFailure', () => {
18+
const op = manager.addOperation({
19+
type: 'org:add-user',
20+
params: { org: 'myorg', user: 'alice', role: 'developer' },
21+
description: 'Add alice',
22+
command: 'npm org set myorg alice developer',
23+
})
24+
manager.approveOperation(op.id)
25+
26+
const result = manager.executeOperations({
27+
results: {
28+
[op.id]: {
29+
exitCode: 1,
30+
stderr: 'auth failure',
31+
authFailure: true,
32+
},
33+
},
34+
})
35+
36+
expect(result.authFailure).toBe(true)
37+
expect(result.results).toHaveLength(1)
38+
expect(result.results[0]!.result.authFailure).toBe(true)
39+
})
40+
41+
it('returns authFailure as undefined when no operations have auth failures', () => {
42+
const op = manager.addOperation({
43+
type: 'org:add-user',
44+
params: { org: 'myorg', user: 'alice', role: 'developer' },
45+
description: 'Add alice',
46+
command: 'npm org set myorg alice developer',
47+
})
48+
manager.approveOperation(op.id)
49+
50+
const result = manager.executeOperations()
51+
52+
// Default success path -- no auth failure
53+
expect(result.authFailure).toBeFalsy()
54+
})
55+
56+
it('collects and deduplicates urls from operation results', () => {
57+
const op1 = manager.addOperation({
58+
type: 'org:add-user',
59+
params: { org: 'myorg', user: 'alice', role: 'developer' },
60+
description: 'Add alice',
61+
command: 'npm org set myorg alice developer',
62+
})
63+
const op2 = manager.addOperation({
64+
type: 'org:add-user',
65+
params: { org: 'myorg', user: 'bob', role: 'developer' },
66+
description: 'Add bob',
67+
command: 'npm org set myorg bob developer',
68+
})
69+
manager.approveOperation(op1.id)
70+
manager.approveOperation(op2.id)
71+
72+
const result = manager.executeOperations({
73+
results: {
74+
[op1.id]: {
75+
exitCode: 0,
76+
stdout: 'ok',
77+
urls: ['https://npmjs.com/auth/abc'],
78+
},
79+
[op2.id]: {
80+
exitCode: 0,
81+
stdout: 'ok',
82+
urls: ['https://npmjs.com/auth/abc', 'https://npmjs.com/auth/def'],
83+
},
84+
},
85+
})
86+
87+
expect(result.urls).toBeDefined()
88+
// Should be deduplicated
89+
expect(result.urls).toEqual(['https://npmjs.com/auth/abc', 'https://npmjs.com/auth/def'])
90+
})
91+
92+
it('returns urls as undefined when no operations have urls', () => {
93+
const op = manager.addOperation({
94+
type: 'org:add-user',
95+
params: { org: 'myorg', user: 'alice', role: 'developer' },
96+
description: 'Add alice',
97+
command: 'npm org set myorg alice developer',
98+
})
99+
manager.approveOperation(op.id)
100+
101+
const result = manager.executeOperations()
102+
103+
expect(result.urls).toBeUndefined()
104+
})
105+
106+
it('returns otpRequired when a configured result requires OTP and none provided', () => {
107+
const op = manager.addOperation({
108+
type: 'org:add-user',
109+
params: { org: 'myorg', user: 'alice', role: 'developer' },
110+
description: 'Add alice',
111+
command: 'npm org set myorg alice developer',
112+
})
113+
manager.approveOperation(op.id)
114+
115+
const result = manager.executeOperations({
116+
results: {
117+
[op.id]: {
118+
exitCode: 1,
119+
stderr: 'otp required',
120+
requiresOtp: true,
121+
},
122+
},
123+
})
124+
125+
expect(result.otpRequired).toBe(true)
126+
})
127+
128+
it('returns both authFailure and urls together', () => {
129+
const op1 = manager.addOperation({
130+
type: 'org:add-user',
131+
params: { org: 'myorg', user: 'alice', role: 'developer' },
132+
description: 'Add alice',
133+
command: 'npm org set myorg alice developer',
134+
})
135+
const op2 = manager.addOperation({
136+
type: 'org:add-user',
137+
params: { org: 'myorg', user: 'bob', role: 'developer' },
138+
description: 'Add bob',
139+
command: 'npm org set myorg bob developer',
140+
})
141+
manager.approveOperation(op1.id)
142+
manager.approveOperation(op2.id)
143+
144+
const result = manager.executeOperations({
145+
results: {
146+
[op1.id]: {
147+
exitCode: 1,
148+
stderr: 'auth failure',
149+
authFailure: true,
150+
urls: ['https://npmjs.com/login'],
151+
},
152+
[op2.id]: {
153+
exitCode: 0,
154+
stdout: 'ok',
155+
},
156+
},
157+
})
158+
159+
expect(result.authFailure).toBe(true)
160+
expect(result.urls).toEqual(['https://npmjs.com/login'])
161+
})
162+
})

0 commit comments

Comments
 (0)