forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1/**
2 * E2E tests for connector-authenticated features.
3 *
4 * These tests use a mock connector server (started in global setup)
5 * to test features that require being logged in via the connector.
6 *
7 * All tests run serially because they share a single mock connector server
8 * whose state is reset before each test via `mockConnector.reset()`.
9 */
10
11import type { Page } from '@playwright/test'
12import { test, expect } from './helpers/fixtures'
13
14test.describe.configure({ mode: 'serial' })
15
16/**
17 * When connected, the header shows "packages" and "orgs" links scoped to the user.
18 * This helper waits for the packages link to appear as proof of successful connection.
19 */
20async function expectConnected(page: Page, username = 'testuser') {
21 await expect(page.locator(`a[href="/~${username}"]`, { hasText: 'packages' })).toBeVisible({
22 timeout: 10_000,
23 })
24}
25
26/**
27 * Open the connector modal by clicking the account menu button, then clicking
28 * the npm CLI menu item inside the dropdown.
29 */
30async function openConnectorModal(page: Page) {
31 // The AccountMenu button has aria-haspopup="true"
32 await page.locator('button[aria-haspopup="true"]').click()
33
34 // In the dropdown menu, click the npm CLI item (menuitem containing ~testuser)
35 await page
36 .getByRole('menuitem')
37 .filter({ hasText: /~testuser/ })
38 .click()
39
40 // Wait for the dialog to appear
41 await expect(page.getByRole('dialog')).toBeVisible()
42}
43
44test.describe('Connector Connection', () => {
45 test('connects via URL params and shows connected state', async ({
46 page,
47 gotoConnected,
48 mockConnector,
49 }) => {
50 await mockConnector.setUserOrgs(['@testorg'])
51 await gotoConnected('/')
52
53 // Header should show "packages" link for the connected user
54 await expectConnected(page)
55 })
56
57 test('opens connector modal and shows connected user', async ({ page, gotoConnected }) => {
58 await gotoConnected('/')
59 await expectConnected(page)
60
61 await openConnectorModal(page)
62
63 // The modal should show the connected user
64 await expect(page.getByRole('dialog')).toContainText('testuser')
65 })
66
67 test('can disconnect from the connector', async ({ page, gotoConnected }) => {
68 await gotoConnected('/')
69 await expectConnected(page)
70
71 await openConnectorModal(page)
72
73 const modal = page.getByRole('dialog')
74
75 // Click disconnect button
76 await modal.getByRole('button', { name: /disconnect/i }).click()
77
78 // Close the modal
79 await modal.getByRole('button', { name: /close/i }).click()
80
81 // The "packages" link should disappear since we're disconnected
82 await expect(page.locator('a[href="/~testuser"]', { hasText: 'packages' })).not.toBeVisible({
83 timeout: 5000,
84 })
85
86 // The account menu button should now show "connect" text (the main button, not dropdown items)
87 await expect(page.getByRole('button', { name: 'connect', exact: true })).toBeVisible()
88 })
89})
90
91test.describe('Organization Management', () => {
92 test.beforeEach(async ({ mockConnector }) => {
93 await mockConnector.setOrgData('@testorg', {
94 users: {
95 testuser: 'owner',
96 member1: 'admin',
97 member2: 'developer',
98 },
99 teams: ['core', 'docs'],
100 teamMembers: {
101 core: ['testuser', 'member1'],
102 docs: ['member2'],
103 },
104 })
105 await mockConnector.setUserOrgs(['@testorg'])
106 })
107
108 test('shows org members when connected', async ({ page, gotoConnected }) => {
109 await gotoConnected('/@testorg')
110
111 // The org management region contains the members panel
112 const orgManagement = page.getByRole('region', { name: /organization management/i })
113 await expect(orgManagement).toBeVisible({ timeout: 10_000 })
114
115 // Should show the members list
116 const membersList = page.getByRole('list', { name: /organization members/i })
117 await expect(membersList).toBeVisible({ timeout: 10_000 })
118
119 // Members are shown as ~username links
120 await expect(membersList.getByRole('link', { name: '~testuser' })).toBeVisible()
121 await expect(membersList.getByRole('link', { name: '~member1' })).toBeVisible()
122 await expect(membersList.getByRole('link', { name: '~member2' })).toBeVisible()
123 })
124
125 test('can filter members by role', async ({ page, gotoConnected }) => {
126 await gotoConnected('/@testorg')
127
128 const orgManagement = page.getByRole('region', { name: /organization management/i })
129 await expect(orgManagement).toBeVisible({ timeout: 10_000 })
130
131 const membersList = page.getByRole('list', { name: /organization members/i })
132 await expect(membersList).toBeVisible({ timeout: 10_000 })
133
134 // Click the "admin" filter button (inside "Filter by role" group)
135 await orgManagement
136 .getByRole('group', { name: /filter by role/i })
137 .getByRole('button', { name: /admin/i })
138 .click()
139
140 // Should only show admin member
141 await expect(membersList.getByRole('link', { name: '~member1' })).toBeVisible()
142 await expect(membersList.getByRole('link', { name: '~testuser' })).not.toBeVisible()
143 await expect(membersList.getByRole('link', { name: '~member2' })).not.toBeVisible()
144 })
145
146 test('can search members by name', async ({ page, gotoConnected }) => {
147 await gotoConnected('/@testorg')
148
149 const orgManagement = page.getByRole('region', { name: /organization management/i })
150 await expect(orgManagement).toBeVisible({ timeout: 10_000 })
151
152 const membersList = page.getByRole('list', { name: /organization members/i })
153 await expect(membersList).toBeVisible({ timeout: 10_000 })
154
155 const searchInput = orgManagement.getByRole('searchbox', { name: /filter members/i })
156 await searchInput.fill('member1')
157
158 // Should only show matching member
159 await expect(membersList.getByRole('link', { name: '~member1' })).toBeVisible()
160 await expect(membersList.getByRole('link', { name: '~testuser' })).not.toBeVisible()
161 await expect(membersList.getByRole('link', { name: '~member2' })).not.toBeVisible()
162 })
163
164 test('can add a new member operation', async ({ page, gotoConnected, mockConnector }) => {
165 await gotoConnected('/@testorg')
166
167 const orgManagement = page.getByRole('region', { name: /organization management/i })
168 await expect(orgManagement).toBeVisible({ timeout: 10_000 })
169
170 // Click "Add member" button
171 await orgManagement.getByRole('button', { name: /add member/i }).click()
172
173 // Wait for the add-member form to appear
174 const usernameInput = orgManagement.locator('#new-member-username')
175 await expect(usernameInput).toBeVisible({ timeout: 5000 })
176
177 // Fill in the form
178 await usernameInput.fill('newuser')
179
180 // Select role (SelectField renders id on the <select>, not name)
181 await orgManagement.locator('#new-member-role').selectOption('admin')
182
183 // Submit
184 await Promise.all([
185 page.waitForResponse(resp => resp.url().includes('/operations') && resp.ok()),
186 orgManagement.getByRole('button', { name: /^add$/i }).click(),
187 ])
188
189 const operations = await mockConnector.getOperations()
190 expect(operations).toHaveLength(1)
191 expect(operations[0]?.type).toBe('org:add-user')
192 expect(operations[0]?.params.user).toBe('newuser')
193 expect(operations[0]?.params.role).toBe('admin')
194 })
195
196 test('can remove a member (adds operation)', async ({ page, gotoConnected, mockConnector }) => {
197 await gotoConnected('/@testorg')
198
199 const orgManagement = page.getByRole('region', { name: /organization management/i })
200 await expect(orgManagement).toBeVisible({ timeout: 10_000 })
201
202 await Promise.all([
203 page.waitForResponse(resp => resp.url().includes('/operations') && resp.ok()),
204 orgManagement.getByRole('button', { name: /remove member2/i }).click(),
205 ])
206
207 const operations = await mockConnector.getOperations()
208 expect(operations).toHaveLength(1)
209 expect(operations[0]?.type).toBe('org:rm-user')
210 expect(operations[0]?.params.user).toBe('member2')
211 })
212
213 test('can change member role (adds operation)', async ({
214 page,
215 gotoConnected,
216 mockConnector,
217 }) => {
218 await gotoConnected('/@testorg')
219
220 const orgManagement = page.getByRole('region', { name: /organization management/i })
221 await expect(orgManagement).toBeVisible({ timeout: 10_000 })
222
223 const roleSelect = orgManagement.locator('#role-member2')
224 await expect(roleSelect).toBeVisible({ timeout: 5000 })
225 await Promise.all([
226 page.waitForResponse(resp => resp.url().includes('/operations') && resp.ok()),
227 roleSelect.selectOption('admin'),
228 ])
229
230 const operations = await mockConnector.getOperations()
231 expect(operations).toHaveLength(1)
232 expect(operations[0]?.type).toBe('org:add-user')
233 expect(operations[0]?.params.user).toBe('member2')
234 expect(operations[0]?.params.role).toBe('admin')
235 })
236})
237
238test.describe('Package Access Controls', () => {
239 test.beforeEach(async ({ mockConnector }) => {
240 await mockConnector.setOrgData('@nuxt', {
241 users: { testuser: 'owner' },
242 teams: ['core', 'docs', 'triage'],
243 })
244 await mockConnector.setUserOrgs(['@nuxt'])
245 await mockConnector.setPackageData('@nuxt/kit', {
246 collaborators: {
247 'nuxt:core': 'read-write',
248 'nuxt:docs': 'read-only',
249 },
250 })
251 })
252
253 /**
254 * Helper: connect on home page then navigate to the package page.
255 * Verifies connection is established before navigating.
256 */
257 async function goToPackageConnected(page: Page, gotoConnected: (path: string) => Promise<void>) {
258 await gotoConnected('/')
259 await expectConnected(page)
260 await page.goto('/package/@nuxt/kit')
261 await expect(page.locator('h1')).toContainText('kit', { timeout: 30_000 })
262 }
263
264 test('shows team access section on scoped package when connected', async ({
265 page,
266 gotoConnected,
267 }) => {
268 await goToPackageConnected(page, gotoConnected)
269
270 await expect(accessSection(page)).toBeVisible({ timeout: 15_000 })
271 await expect(page.getByRole('heading', { name: /team access/i })).toBeVisible()
272 })
273
274 test('displays collaborators with correct permissions', async ({ page, gotoConnected }) => {
275 await goToPackageConnected(page, gotoConnected)
276
277 await expect(accessSection(page)).toBeVisible({ timeout: 15_000 })
278
279 const teamList = page.getByRole('list', { name: /team access list/i })
280 await expect(teamList).toBeVisible({ timeout: 10_000 })
281
282 await expect(teamList.getByText('core')).toBeVisible()
283 await expect(teamList.locator('span', { hasText: 'rw' })).toBeVisible()
284 await expect(teamList.getByText('docs')).toBeVisible()
285 await expect(teamList.locator('span', { hasText: 'ro' })).toBeVisible()
286 })
287
288 test('can grant team access (creates operation)', async ({
289 page,
290 gotoConnected,
291 mockConnector,
292 }) => {
293 await goToPackageConnected(page, gotoConnected)
294
295 const section = accessSection(page)
296 await expect(section).toBeVisible({ timeout: 15_000 })
297
298 await section.getByRole('button', { name: /grant team access/i }).click()
299
300 const teamSelect = section.locator('#grant-team-select')
301 await expect(teamSelect).toBeVisible()
302 await expect(teamSelect.locator('option')).toHaveCount(4, { timeout: 10_000 })
303 await teamSelect.selectOption({ label: 'nuxt:triage' })
304
305 await section.locator('#grant-permission-select').selectOption('read-write')
306
307 await Promise.all([
308 page.waitForResponse(resp => resp.url().includes('/operations') && resp.ok()),
309 section.getByRole('button', { name: /^grant$/i }).click(),
310 ])
311
312 const operations = await mockConnector.getOperations()
313 expect(operations).toHaveLength(1)
314 expect(operations[0]?.type).toBe('access:grant')
315 expect(operations[0]?.params.scopeTeam).toBe('@nuxt:triage')
316 expect(operations[0]?.params.pkg).toBe('@nuxt/kit')
317 expect(operations[0]?.params.permission).toBe('read-write')
318 })
319
320 test('can revoke team access (creates operation)', async ({
321 page,
322 gotoConnected,
323 mockConnector,
324 }) => {
325 await goToPackageConnected(page, gotoConnected)
326
327 const section = accessSection(page)
328 await expect(section).toBeVisible({ timeout: 15_000 })
329
330 const teamList = page.getByRole('list', { name: /team access list/i })
331 await expect(teamList).toBeVisible({ timeout: 10_000 })
332
333 await Promise.all([
334 page.waitForResponse(resp => resp.url().includes('/operations') && resp.ok()),
335 section.getByRole('button', { name: /revoke docs access/i }).click(),
336 ])
337
338 const operations = await mockConnector.getOperations()
339 expect(operations).toHaveLength(1)
340 expect(operations[0]?.type).toBe('access:revoke')
341 expect(operations[0]?.params.scopeTeam).toBe('nuxt:docs')
342 expect(operations[0]?.params.pkg).toBe('@nuxt/kit')
343 })
344
345 test('can cancel grant access form', async ({ page, gotoConnected }) => {
346 await goToPackageConnected(page, gotoConnected)
347
348 const section = accessSection(page)
349 await expect(section).toBeVisible({ timeout: 15_000 })
350
351 await section.getByRole('button', { name: /grant team access/i }).click()
352 const teamSelect = section.locator('#grant-team-select')
353 await expect(teamSelect).toBeVisible()
354
355 await section.getByRole('button', { name: /cancel granting access/i }).click()
356 await expect(teamSelect).not.toBeVisible()
357 await expect(section.getByRole('button', { name: /grant team access/i })).toBeVisible()
358 })
359})
360
361test.describe('Operations Queue', () => {
362 test('shows operations in connector modal', async ({ page, gotoConnected, mockConnector }) => {
363 await mockConnector.addOperation({
364 type: 'org:add-user',
365 params: { org: '@testorg', user: 'newuser', role: 'developer' },
366 description: 'Add @newuser to @testorg as developer',
367 command: 'npm org set @testorg newuser developer',
368 })
369 await mockConnector.addOperation({
370 type: 'org:rm-user',
371 params: { org: '@testorg', user: 'olduser' },
372 description: 'Remove @olduser from @testorg',
373 command: 'npm org rm @testorg olduser',
374 })
375
376 await gotoConnected('/')
377 await expectConnected(page)
378
379 await openConnectorModal(page)
380
381 const modal = page.getByRole('dialog')
382 await expect(modal).toContainText('Add @newuser')
383 await expect(modal).toContainText('Remove @olduser')
384 })
385
386 test('can approve and execute operations', async ({ page, gotoConnected, mockConnector }) => {
387 await mockConnector.addOperation({
388 type: 'org:add-user',
389 params: { org: '@testorg', user: 'newuser', role: 'developer' },
390 description: 'Add @newuser to @testorg',
391 command: 'npm org set @testorg newuser developer',
392 })
393
394 await gotoConnected('/')
395 await expectConnected(page)
396
397 await openConnectorModal(page)
398
399 const modal = page.getByRole('dialog')
400
401 // Approve all
402 const approveAllBtn = modal.getByRole('button', { name: /approve all/i })
403 await expect(approveAllBtn).toBeVisible({ timeout: 5000 })
404 await Promise.all([
405 page.waitForResponse(resp => resp.url().includes('/approve-all') && resp.ok()),
406 approveAllBtn.click(),
407 ])
408
409 let operations = await mockConnector.getOperations()
410 expect(operations[0]?.status).toBe('approved')
411
412 // Execute
413 const executeBtn = modal.getByRole('button', { name: /execute/i })
414 await expect(executeBtn).toBeVisible({ timeout: 5000 })
415 await Promise.all([
416 page.waitForResponse(resp => resp.url().includes('/execute') && resp.ok()),
417 executeBtn.click(),
418 ])
419
420 operations = await mockConnector.getOperations()
421 expect(operations[0]?.status).toBe('completed')
422 })
423
424 test('can clear pending operations', async ({ page, gotoConnected, mockConnector }) => {
425 await mockConnector.addOperation({
426 type: 'org:add-user',
427 params: { org: '@testorg', user: 'newuser', role: 'developer' },
428 description: 'Add @newuser to @testorg',
429 command: 'npm org set @testorg newuser developer',
430 })
431
432 await gotoConnected('/')
433 await expectConnected(page)
434
435 await openConnectorModal(page)
436
437 const modal = page.getByRole('dialog')
438 await Promise.all([
439 page.waitForResponse(resp => resp.url().includes('/operations/all') && resp.ok()),
440 modal.getByRole('button', { name: /clear/i }).click(),
441 ])
442
443 const operations = await mockConnector.getOperations()
444 expect(operations).toHaveLength(0)
445 })
446})
447
448/** The access section is identified by the "Team Access" heading */
449function accessSection(page: Page) {
450 return page.locator('section:has(#access-heading)')
451}