From 2d9039ed56c19b78dc05f3f510dbdb6cbe836d88 Mon Sep 17 00:00:00 2001 From: neutrino2211 Date: Sun, 1 Mar 2026 17:16:44 +0100 Subject: [PATCH 1/6] Add tests for new components --- i18n/locales/en.json | 18 +- i18n/schema.json | 42 ++ lunaria/files/en-GB.json | 18 +- lunaria/files/en-US.json | 18 +- .../components/Package/Maintainers.spec.ts | 360 ++++++++++++++++++ .../composables/use-package-selection.spec.ts | 239 ++++++++++++ test/unit/a11y-component-coverage.spec.ts | 9 + test/unit/cli/mock-state.spec.ts | 85 +++++ test/unit/cli/server.spec.ts | 38 ++ 9 files changed, 821 insertions(+), 6 deletions(-) create mode 100644 test/nuxt/components/Package/Maintainers.spec.ts create mode 100644 test/nuxt/composables/use-package-selection.spec.ts diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 2aecdcd92..524918f0c 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -383,7 +383,14 @@ "cancel_add": "Cancel adding owner", "add_owner": "+ Add owner", "show_more": "(show {count} more)", - "show_less": "(show fewer)" + "show_less": "(show fewer)", + "remove": { + "title": "Remove Owner", + "warning": "This action will revoke ownership.", + "impact": "{user} will no longer be able to publish updates to {package}. This operation will be queued and executed when you approve the operation in the connector.", + "removing": "Removing...", + "confirm": "Remove owner" + } }, "trends": { "granularity": "Granularity", @@ -505,7 +512,14 @@ }, "grant_button": "grant", "cancel_grant": "Cancel granting access", - "grant_access": "+ Grant team access" + "grant_access": "+ Grant team access", + "revoke": { + "title": "Revoke Team Access", + "warning": "This action cannot be undone", + "impact": "Team \"{team}\" will lose access to {package}. This operation will be queued and executed when you approve the operation in the connector.", + "confirm": "Revoke Access", + "revoking": "Revoking..." + } }, "list": { "filter_label": "Filter packages", diff --git a/i18n/schema.json b/i18n/schema.json index f01ef79e8..7756d911c 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -1155,6 +1155,27 @@ }, "show_less": { "type": "string" + }, + "remove": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "warning": { + "type": "string" + }, + "impact": { + "type": "string" + }, + "removing": { + "type": "string" + }, + "confirm": { + "type": "string" + } + }, + "additionalProperties": false } }, "additionalProperties": false @@ -1521,6 +1542,27 @@ }, "grant_access": { "type": "string" + }, + "revoke": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "warning": { + "type": "string" + }, + "impact": { + "type": "string" + }, + "confirm": { + "type": "string" + }, + "revoking": { + "type": "string" + } + }, + "additionalProperties": false } }, "additionalProperties": false diff --git a/lunaria/files/en-GB.json b/lunaria/files/en-GB.json index 63b433f80..39e955bfb 100644 --- a/lunaria/files/en-GB.json +++ b/lunaria/files/en-GB.json @@ -382,7 +382,14 @@ "cancel_add": "Cancel adding owner", "add_owner": "+ Add owner", "show_more": "(show {count} more)", - "show_less": "(show fewer)" + "show_less": "(show fewer)", + "remove": { + "title": "Remove Owner", + "warning": "This action will revoke ownership.", + "impact": "{user} will no longer be able to publish updates to {package}. This operation will be queued and executed when you approve the operation in the connector.", + "removing": "Removing...", + "confirm": "Remove owner" + } }, "trends": { "granularity": "Granularity", @@ -504,7 +511,14 @@ }, "grant_button": "grant", "cancel_grant": "Cancel granting access", - "grant_access": "+ Grant team access" + "grant_access": "+ Grant team access", + "revoke": { + "title": "Revoke Team Access", + "warning": "This action cannot be undone", + "impact": "Team \"{team}\" will lose access to {package}. This operation will be queued and executed when you approve the operation in the connector.", + "confirm": "Revoke Access", + "revoking": "Revoking..." + } }, "list": { "filter_label": "Filter packages", diff --git a/lunaria/files/en-US.json b/lunaria/files/en-US.json index 108aacb17..32b597805 100644 --- a/lunaria/files/en-US.json +++ b/lunaria/files/en-US.json @@ -382,7 +382,14 @@ "cancel_add": "Cancel adding owner", "add_owner": "+ Add owner", "show_more": "(show {count} more)", - "show_less": "(show fewer)" + "show_less": "(show fewer)", + "remove": { + "title": "Remove Owner", + "warning": "This action will revoke ownership.", + "impact": "{user} will no longer be able to publish updates to {package}. This operation will be queued and executed when you approve the operation in the connector.", + "removing": "Removing...", + "confirm": "Remove owner" + } }, "trends": { "granularity": "Granularity", @@ -504,7 +511,14 @@ }, "grant_button": "grant", "cancel_grant": "Cancel granting access", - "grant_access": "+ Grant team access" + "grant_access": "+ Grant team access", + "revoke": { + "title": "Revoke Team Access", + "warning": "This action cannot be undone", + "impact": "Team \"{team}\" will lose access to {package}. This operation will be queued and executed when you approve the operation in the connector.", + "confirm": "Revoke Access", + "revoking": "Revoking..." + } }, "list": { "filter_label": "Filter packages", diff --git a/test/nuxt/components/Package/Maintainers.spec.ts b/test/nuxt/components/Package/Maintainers.spec.ts new file mode 100644 index 000000000..586b55c05 --- /dev/null +++ b/test/nuxt/components/Package/Maintainers.spec.ts @@ -0,0 +1,360 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mockNuxtImport, mountSuspended } from '@nuxt/test-utils/runtime' +import { ref, computed, readonly, nextTick } from 'vue' +import type { VueWrapper } from '@vue/test-utils' +import type { PendingOperation } from '../../../../cli/src/types' +import { PackageMaintainers } from '#components' + +// Mock state that will be controlled by tests +const mockState = ref({ + connected: false, + connecting: false, + npmUser: null as string | null, + avatar: null as string | null, + operations: [] as PendingOperation[], + error: null as string | null, + lastExecutionTime: null as number | null, +}) + +// Mock connector methods +const mockAddOperation = vi.fn() +const mockListPackageCollaborators = vi.fn() +const mockListTeamUsers = vi.fn() + +// Create the mock composable function +function createMockUseConnector() { + return { + state: readonly(mockState), + isConnected: computed(() => mockState.value.connected), + isConnecting: computed(() => mockState.value.connecting), + npmUser: computed(() => mockState.value.npmUser), + avatar: computed(() => mockState.value.avatar), + error: computed(() => mockState.value.error), + lastExecutionTime: computed(() => mockState.value.lastExecutionTime), + operations: computed(() => mockState.value.operations), + connect: vi.fn().mockResolvedValue(true), + reconnect: vi.fn().mockResolvedValue(true), + disconnect: vi.fn(), + refreshState: vi.fn().mockResolvedValue(undefined), + addOperation: mockAddOperation, + addOperations: vi.fn().mockResolvedValue([]), + removeOperation: vi.fn().mockResolvedValue(true), + clearOperations: vi.fn().mockResolvedValue(0), + approveOperation: vi.fn().mockResolvedValue(true), + retryOperation: vi.fn().mockResolvedValue(true), + approveAll: vi.fn().mockResolvedValue(0), + executeOperations: vi.fn().mockResolvedValue({ success: true }), + listOrgUsers: vi.fn().mockResolvedValue(null), + listOrgTeams: vi.fn().mockResolvedValue(null), + listTeamUsers: mockListTeamUsers, + listTeamPackages: vi.fn().mockResolvedValue(null), + listPackageCollaborators: mockListPackageCollaborators, + listUserPackages: vi.fn().mockResolvedValue(null), + listUserOrgs: vi.fn().mockResolvedValue(null), + } +} + +function resetMockState() { + mockState.value = { + connected: false, + connecting: false, + npmUser: null, + avatar: null, + operations: [], + error: null, + lastExecutionTime: null, + } + mockAddOperation.mockReset() + mockListPackageCollaborators.mockReset() + mockListTeamUsers.mockReset() +} + +function simulateConnect(npmUser = 'testuser') { + mockState.value.connected = true + mockState.value.npmUser = npmUser +} + +mockNuxtImport('useConnector', () => { + return createMockUseConnector +}) + +// Track current wrapper for cleanup +let currentWrapper: VueWrapper | null = null + +/** + * Get the remove owner confirmation modal dialog element from the document body. + */ +function getRemoveDialog(): HTMLDialogElement | null { + return document.body.querySelector('dialog#remove-owner-modal') +} + +/** + * Mount the component with maintainers. + */ +async function mountWithMaintainers( + maintainers: Array<{ name?: string; email?: string }>, + packageName = '@myorg/my-package', + connected = true, +) { + if (connected) { + simulateConnect() + mockListPackageCollaborators.mockResolvedValue({}) + } + + currentWrapper = await mountSuspended(PackageMaintainers, { + props: { + packageName, + maintainers, + }, + attachTo: document.body, + }) + + // Wait for async data loading + await nextTick() + await nextTick() + + return currentWrapper +} + +// Reset state before each test +beforeEach(() => { + resetMockState() +}) + +afterEach(() => { + vi.clearAllMocks() + if (currentWrapper) { + currentWrapper.unmount() + currentWrapper = null + } +}) + +describe('PackageMaintainers', () => { + describe('Remove owner confirmation dialog', () => { + it('does not show remove dialog initially', async () => { + await mountWithMaintainers([{ name: 'developer1' }, { name: 'developer2' }]) + + const dialog = getRemoveDialog() + expect(dialog?.open).toBeFalsy() + }) + + it('opens remove dialog when clicking remove button on a maintainer', async () => { + await mountWithMaintainers([{ name: 'developer1' }, { name: 'developer2' }]) + + // Find and click the remove button (first one, for developer1) + const removeBtn = document.querySelector('button[aria-label*="Remove"]') as HTMLButtonElement + expect(removeBtn).toBeTruthy() + + removeBtn?.click() + await nextTick() + + const dialog = getRemoveDialog() + expect(dialog?.open).toBe(true) + }) + + it('shows username in the confirmation dialog', async () => { + await mountWithMaintainers([{ name: 'developer1' }, { name: 'developer2' }]) + + // Open the dialog for developer1 + const removeBtn = document.querySelector('button[aria-label*="Remove"]') as HTMLButtonElement + removeBtn?.click() + await nextTick() + + const dialog = getRemoveDialog() + expect(dialog?.textContent).toContain('developer1') + }) + + it('shows warning message in the confirmation dialog', async () => { + await mountWithMaintainers([{ name: 'developer1' }, { name: 'developer2' }]) + + // Open the dialog + const removeBtn = document.querySelector('button[aria-label*="Remove"]') as HTMLButtonElement + removeBtn?.click() + await nextTick() + + const dialog = getRemoveDialog() + // Should contain the warning about revoking ownership + expect(dialog?.textContent).toContain('revoke ownership') + }) + + it('closes dialog when clicking close button', async () => { + await mountWithMaintainers([{ name: 'developer1' }, { name: 'developer2' }]) + + // Open the dialog + const removeBtn = document.querySelector('button[aria-label*="Remove"]') as HTMLButtonElement + removeBtn?.click() + await nextTick() + + const dialog = getRemoveDialog() + expect(dialog?.open).toBe(true) + + // Find and click close button + const buttons = dialog?.querySelectorAll('button') + const closeBtn = Array.from(buttons || []).find(b => + b.textContent?.toLowerCase().includes('close'), + ) as HTMLButtonElement + closeBtn?.click() + await nextTick() + + expect(dialog?.open).toBe(false) + }) + + it('calls addOperation when confirming remove', async () => { + mockAddOperation.mockResolvedValue({ + id: '0000000000000001', + type: 'owner:rm', + params: { user: 'developer1', pkg: '@myorg/my-package' }, + description: 'Remove @developer1 from @myorg/my-package', + command: 'npm owner rm developer1 @myorg/my-package', + status: 'pending', + createdAt: Date.now(), + }) + + await mountWithMaintainers([{ name: 'developer1' }, { name: 'developer2' }]) + + // Open the dialog + const removeBtn = document.querySelector('button[aria-label*="Remove"]') as HTMLButtonElement + removeBtn?.click() + await nextTick() + + const dialog = getRemoveDialog() + + // Find and click confirm button + const buttons = dialog?.querySelectorAll('button') + const confirmBtn = Array.from(buttons || []).find(b => + b.textContent?.toLowerCase().includes('remove owner'), + ) as HTMLButtonElement + confirmBtn?.click() + await nextTick() + + expect(mockAddOperation).toHaveBeenCalledWith({ + type: 'owner:rm', + params: { + user: 'developer1', + pkg: '@myorg/my-package', + }, + description: 'Remove @developer1 from @myorg/my-package', + command: 'npm owner rm developer1 @myorg/my-package', + }) + }) + + it('closes dialog after successful remove', async () => { + mockAddOperation.mockResolvedValue({ + id: '0000000000000001', + type: 'owner:rm', + params: { user: 'developer1', pkg: '@myorg/my-package' }, + description: 'Remove @developer1 from @myorg/my-package', + command: 'npm owner rm developer1 @myorg/my-package', + status: 'pending', + createdAt: Date.now(), + }) + + await mountWithMaintainers([{ name: 'developer1' }, { name: 'developer2' }]) + + // Open the dialog + const removeBtn = document.querySelector('button[aria-label*="Remove"]') as HTMLButtonElement + removeBtn?.click() + await nextTick() + + const dialog = getRemoveDialog() + + // Find and click confirm button + const buttons = dialog?.querySelectorAll('button') + const confirmBtn = Array.from(buttons || []).find(b => + b.textContent?.toLowerCase().includes('remove owner'), + ) as HTMLButtonElement + confirmBtn?.click() + await nextTick() + await nextTick() + + expect(dialog?.open).toBe(false) + }) + + it('shows error message when remove fails', async () => { + mockAddOperation.mockResolvedValue(null) + mockState.value.error = 'Connection failed' + + await mountWithMaintainers([{ name: 'developer1' }, { name: 'developer2' }]) + + // Open the dialog + const removeBtn = document.querySelector('button[aria-label*="Remove"]') as HTMLButtonElement + removeBtn?.click() + await nextTick() + + const dialog = getRemoveDialog() + + // Find and click confirm button + const buttons = dialog?.querySelectorAll('button') + const confirmBtn = Array.from(buttons || []).find(b => + b.textContent?.toLowerCase().includes('remove owner'), + ) as HTMLButtonElement + confirmBtn?.click() + await nextTick() + await nextTick() + + // Dialog should stay open with error + expect(dialog?.open).toBe(true) + const alert = dialog?.querySelector('[role="alert"]') + expect(alert).toBeTruthy() + }) + + it('does not show remove button for self (current user)', async () => { + // Connected as testuser + simulateConnect('testuser') + + await mountWithMaintainers([{ name: 'testuser' }, { name: 'developer2' }]) + + // Should have only one remove button (for developer2, not testuser) + const removeButtons = document.querySelectorAll('button[aria-label*="Remove"]') + expect(removeButtons.length).toBe(1) + }) + + it('does not show remove buttons when not connected', async () => { + await mountWithMaintainers( + [{ name: 'developer1' }, { name: 'developer2' }], + '@myorg/my-package', + false, + ) + + const removeButtons = document.querySelectorAll('button[aria-label*="Remove"]') + expect(removeButtons.length).toBe(0) + }) + }) + + describe('Component visibility', () => { + it('renders when maintainers are provided', async () => { + await mountWithMaintainers([{ name: 'developer1' }], '@myorg/my-package', false) + + // The section should be rendered + const section = document.querySelector('[id="maintainers"]') + expect(section).not.toBeNull() + }) + + it('does not render when no maintainers', async () => { + currentWrapper = await mountSuspended(PackageMaintainers, { + props: { + packageName: '@myorg/my-package', + maintainers: [], + }, + attachTo: document.body, + }) + await nextTick() + + // The section should not be rendered + const section = document.querySelector('[id="maintainers"]') + expect(section).toBeNull() + }) + + it('renders maintainer list', async () => { + await mountWithMaintainers( + [{ name: 'developer1' }, { name: 'developer2' }], + '@myorg/my-package', + false, + ) + + const list = document.querySelector('ul[aria-label*="maintainers"]') + expect(list).not.toBeNull() + expect(list?.querySelectorAll('li').length).toBe(2) + }) + }) +}) diff --git a/test/nuxt/composables/use-package-selection.spec.ts b/test/nuxt/composables/use-package-selection.spec.ts new file mode 100644 index 000000000..3c2918d8d --- /dev/null +++ b/test/nuxt/composables/use-package-selection.spec.ts @@ -0,0 +1,239 @@ +import { beforeEach, describe, expect, it } from 'vitest' + +// Direct import since this composable doesn't use other Nuxt-specific features +import { usePackageSelection } from '~/composables/usePackageSelection' + +describe('usePackageSelection', () => { + let selection: ReturnType + + beforeEach(() => { + selection = usePackageSelection() + }) + + describe('initial state', () => { + it('starts with empty selection', () => { + expect(selection.selectedCount.value).toBe(0) + expect(selection.hasSelection.value).toBe(false) + expect(selection.selectedPackages.value).toEqual([]) + }) + + it('starts in non-selection mode', () => { + expect(selection.isSelectionMode.value).toBe(false) + }) + }) + + describe('toggle', () => { + it('adds package when not selected', () => { + selection.toggle('@nuxt/kit') + + expect(selection.isSelected('@nuxt/kit')).toBe(true) + expect(selection.selectedCount.value).toBe(1) + }) + + it('removes package when already selected', () => { + selection.toggle('@nuxt/kit') + selection.toggle('@nuxt/kit') + + expect(selection.isSelected('@nuxt/kit')).toBe(false) + expect(selection.selectedCount.value).toBe(0) + }) + + it('handles multiple packages', () => { + selection.toggle('@nuxt/kit') + selection.toggle('@nuxt/ui') + selection.toggle('@nuxt/content') + + expect(selection.selectedCount.value).toBe(3) + expect(selection.isSelected('@nuxt/kit')).toBe(true) + expect(selection.isSelected('@nuxt/ui')).toBe(true) + expect(selection.isSelected('@nuxt/content')).toBe(true) + }) + }) + + describe('select/deselect', () => { + it('select adds package', () => { + selection.select('@nuxt/kit') + + expect(selection.isSelected('@nuxt/kit')).toBe(true) + }) + + it('select is idempotent', () => { + selection.select('@nuxt/kit') + selection.select('@nuxt/kit') + + expect(selection.selectedCount.value).toBe(1) + }) + + it('deselect removes package', () => { + selection.select('@nuxt/kit') + selection.deselect('@nuxt/kit') + + expect(selection.isSelected('@nuxt/kit')).toBe(false) + }) + + it('deselect is idempotent', () => { + selection.select('@nuxt/kit') + selection.deselect('@nuxt/kit') + selection.deselect('@nuxt/kit') + + expect(selection.selectedCount.value).toBe(0) + }) + }) + + describe('selectAll', () => { + it('selects all packages from array', () => { + const packages = ['@nuxt/kit', '@nuxt/ui', '@nuxt/content'] + selection.selectAll(packages) + + expect(selection.selectedCount.value).toBe(3) + for (const pkg of packages) { + expect(selection.isSelected(pkg)).toBe(true) + } + }) + + it('adds to existing selection', () => { + selection.select('@nuxt/devtools') + selection.selectAll(['@nuxt/kit', '@nuxt/ui']) + + expect(selection.selectedCount.value).toBe(3) + expect(selection.isSelected('@nuxt/devtools')).toBe(true) + }) + + it('deduplicates when adding already selected packages', () => { + selection.select('@nuxt/kit') + selection.selectAll(['@nuxt/kit', '@nuxt/ui']) + + expect(selection.selectedCount.value).toBe(2) + }) + }) + + describe('deselectAll', () => { + it('clears all selections', () => { + selection.selectAll(['@nuxt/kit', '@nuxt/ui', '@nuxt/content']) + selection.deselectAll() + + expect(selection.selectedCount.value).toBe(0) + expect(selection.hasSelection.value).toBe(false) + }) + }) + + describe('areAllSelected', () => { + it('returns true when all packages are selected', () => { + const packages = ['@nuxt/kit', '@nuxt/ui'] + selection.selectAll(packages) + + expect(selection.areAllSelected(packages)).toBe(true) + }) + + it('returns false when some packages are not selected', () => { + selection.select('@nuxt/kit') + + expect(selection.areAllSelected(['@nuxt/kit', '@nuxt/ui'])).toBe(false) + }) + + it('returns false for empty array', () => { + expect(selection.areAllSelected([])).toBe(false) + }) + }) + + describe('areSomeSelected', () => { + it('returns true when some but not all are selected', () => { + selection.select('@nuxt/kit') + + expect(selection.areSomeSelected(['@nuxt/kit', '@nuxt/ui'])).toBe(true) + }) + + it('returns false when all are selected', () => { + selection.selectAll(['@nuxt/kit', '@nuxt/ui']) + + expect(selection.areSomeSelected(['@nuxt/kit', '@nuxt/ui'])).toBe(false) + }) + + it('returns false when none are selected', () => { + expect(selection.areSomeSelected(['@nuxt/kit', '@nuxt/ui'])).toBe(false) + }) + + it('returns false for empty array', () => { + expect(selection.areSomeSelected([])).toBe(false) + }) + }) + + describe('toggleAll', () => { + it('selects all when none selected', () => { + const packages = ['@nuxt/kit', '@nuxt/ui'] + selection.toggleAll(packages) + + expect(selection.areAllSelected(packages)).toBe(true) + }) + + it('deselects all when all selected', () => { + const packages = ['@nuxt/kit', '@nuxt/ui'] + selection.selectAll(packages) + selection.toggleAll(packages) + + expect(selection.selectedCount.value).toBe(0) + }) + + it('selects all when some selected', () => { + const packages = ['@nuxt/kit', '@nuxt/ui'] + selection.select('@nuxt/kit') + selection.toggleAll(packages) + + expect(selection.areAllSelected(packages)).toBe(true) + }) + }) + + describe('selection mode', () => { + it('enterSelectionMode enables selection mode', () => { + selection.enterSelectionMode() + + expect(selection.isSelectionMode.value).toBe(true) + }) + + it('exitSelectionMode disables selection mode and clears selection', () => { + selection.enterSelectionMode() + selection.selectAll(['@nuxt/kit', '@nuxt/ui']) + selection.exitSelectionMode() + + expect(selection.isSelectionMode.value).toBe(false) + expect(selection.selectedCount.value).toBe(0) + }) + + it('toggleSelectionMode toggles mode on and off', () => { + selection.toggleSelectionMode() + expect(selection.isSelectionMode.value).toBe(true) + + selection.toggleSelectionMode() + expect(selection.isSelectionMode.value).toBe(false) + }) + + it('toggleSelectionMode clears selection when exiting', () => { + selection.enterSelectionMode() + selection.selectAll(['@nuxt/kit', '@nuxt/ui']) + selection.toggleSelectionMode() + + expect(selection.selectedCount.value).toBe(0) + }) + }) + + describe('computed values', () => { + it('selectedPackages returns array of selected package names', () => { + selection.selectAll(['@nuxt/kit', '@nuxt/ui']) + + const packages = selection.selectedPackages.value + expect(packages).toContain('@nuxt/kit') + expect(packages).toContain('@nuxt/ui') + expect(packages.length).toBe(2) + }) + + it('hasSelection reflects whether any packages are selected', () => { + expect(selection.hasSelection.value).toBe(false) + + selection.select('@nuxt/kit') + expect(selection.hasSelection.value).toBe(true) + + selection.deselectAll() + expect(selection.hasSelection.value).toBe(false) + }) + }) +}) diff --git a/test/unit/a11y-component-coverage.spec.ts b/test/unit/a11y-component-coverage.spec.ts index 0f18a000e..e92344a03 100644 --- a/test/unit/a11y-component-coverage.spec.ts +++ b/test/unit/a11y-component-coverage.spec.ts @@ -46,6 +46,15 @@ const SKIPPED_COMPONENTS: Record = { 'SkeletonBlock.vue': 'Already covered indirectly via other component tests', 'SkeletonInline.vue': 'Already covered indirectly via other component tests', 'Button/Group.vue': "Wrapper component, tests wouldn't make much sense here", + + // Bulk operations components - require connector context and complex mock setup + 'Package/BulkActionsToolbar.vue': + 'Toolbar component - requires connector context and selection state', + 'Package/BulkGrantAccessModal.vue': + 'Complex modal - requires connector context and API calls for teams', + 'Package/CopyAccessModal.vue': + 'Complex modal - requires connector context and API calls for collaborators', + 'Org/TeamPackagesPanel.vue': 'Admin panel - requires connector context and API calls for teams', } /** diff --git a/test/unit/cli/mock-state.spec.ts b/test/unit/cli/mock-state.spec.ts index 899883cab..f2a8ab314 100644 --- a/test/unit/cli/mock-state.spec.ts +++ b/test/unit/cli/mock-state.spec.ts @@ -6,6 +6,91 @@ function createManager() { return new MockConnectorStateManager(data) } +describe('MockConnectorStateManager: getTeamPackages', () => { + let manager: MockConnectorStateManager + + beforeEach(() => { + manager = createManager() + manager.connect('test-token') + }) + + it('returns empty object when no packages have team access', () => { + manager.setOrgData('@myorg', { teams: ['developers'] }) + + const packages = manager.getTeamPackages('@myorg', 'developers') + + expect(packages).toEqual({}) + }) + + it('returns packages with team access', () => { + manager.setOrgData('@myorg', { teams: ['developers'] }) + manager.setPackageData('@myorg/package-a', { + collaborators: { '@myorg:developers': 'read-write' }, + }) + manager.setPackageData('@myorg/package-b', { + collaborators: { '@myorg:developers': 'read-only' }, + }) + + const packages = manager.getTeamPackages('@myorg', 'developers') + + expect(packages).toEqual({ + '@myorg/package-a': 'read-write', + '@myorg/package-b': 'read-only', + }) + }) + + it('filters to only packages with the specified team', () => { + manager.setOrgData('@myorg', { teams: ['developers', 'designers'] }) + manager.setPackageData('@myorg/package-a', { + collaborators: { '@myorg:developers': 'read-write' }, + }) + manager.setPackageData('@myorg/package-b', { + collaborators: { '@myorg:designers': 'read-only' }, + }) + manager.setPackageData('@myorg/package-c', { + collaborators: { + '@myorg:developers': 'read-write', + '@myorg:designers': 'read-only', + }, + }) + + const devPackages = manager.getTeamPackages('@myorg', 'developers') + + expect(devPackages).toEqual({ + '@myorg/package-a': 'read-write', + '@myorg/package-c': 'read-write', + }) + }) + + it('handles scope with or without @ prefix', () => { + manager.setPackageData('@myorg/package-a', { + collaborators: { '@myorg:developers': 'read-write' }, + }) + + // With @ + const withAt = manager.getTeamPackages('@myorg', 'developers') + // Without @ + const withoutAt = manager.getTeamPackages('myorg', 'developers') + + expect(withAt).toEqual({ '@myorg/package-a': 'read-write' }) + expect(withoutAt).toEqual({ '@myorg/package-a': 'read-write' }) + }) + + it('does not include user collaborators', () => { + manager.setPackageData('@myorg/package-a', { + collaborators: { + '@myorg:developers': 'read-write', + 'testuser': 'read-write', // User, not team + }, + }) + + const packages = manager.getTeamPackages('@myorg', 'developers') + + expect(packages).toEqual({ '@myorg/package-a': 'read-write' }) + expect(Object.keys(packages)).not.toContain('testuser') + }) +}) + describe('MockConnectorStateManager: executeOperations', () => { let manager: MockConnectorStateManager diff --git a/test/unit/cli/server.spec.ts b/test/unit/cli/server.spec.ts index bd7197f18..110c6a498 100644 --- a/test/unit/cli/server.spec.ts +++ b/test/unit/cli/server.spec.ts @@ -36,6 +36,44 @@ describe('connector server', () => { }) }) + describe('GET /team/:scopeTeam/packages', () => { + it('returns 400 for invalid scope:team format (missing @ prefix)', async () => { + const app = createConnectorApp(TEST_TOKEN) + + const response = await app.fetch( + new Request('http://localhost/team/netlify%3Adevelopers/packages', { + headers: { Authorization: `Bearer ${TEST_TOKEN}` }, + }), + ) + + expect(response.status).toBe(400) + const body = await response.json() + expect(body.message).toContain('Invalid scope:team format') + }) + + it('returns 401 without auth token', async () => { + const app = createConnectorApp(TEST_TOKEN) + + const response = await app.fetch( + new Request('http://localhost/team/@netlify%3Adevelopers/packages'), + ) + + expect(response.status).toBe(401) + }) + + it('returns 401 with invalid auth token', async () => { + const app = createConnectorApp(TEST_TOKEN) + + const response = await app.fetch( + new Request('http://localhost/team/@netlify%3Adevelopers/packages', { + headers: { Authorization: 'Bearer wrong-token' }, + }), + ) + + expect(response.status).toBe(401) + }) + }) + describe('GET /user/packages', () => { it('returns 401 without auth token', async () => { const app = createConnectorApp(TEST_TOKEN) From 5e942268deb7277269e67d5506a3ab66c49ef9b8 Mon Sep 17 00:00:00 2001 From: neutrino2211 Date: Fri, 27 Feb 2026 11:27:57 +0100 Subject: [PATCH 2/6] Use warning modal for maintainer removal as well --- app/components/Package/Maintainers.vue | 128 ++++++++++++++++++++++--- 1 file changed, 116 insertions(+), 12 deletions(-) diff --git a/app/components/Package/Maintainers.vue b/app/components/Package/Maintainers.vue index 340ff9572..df925ae17 100644 --- a/app/components/Package/Maintainers.vue +++ b/app/components/Package/Maintainers.vue @@ -13,6 +13,7 @@ const { addOperation, listPackageCollaborators, listTeamUsers, + error: connectorError, } = useConnector() const showAddOwner = shallowRef(false) @@ -20,6 +21,12 @@ const newOwnerUsername = shallowRef('') const isAdding = shallowRef(false) const showAllMaintainers = shallowRef(false) +// Remove owner confirmation state +const removeDialogRef = useTemplateRef('removeDialogRef') +const removeTarget = shallowRef<{ username: string } | null>(null) +const isRemoving = shallowRef(false) +const removeError = shallowRef(null) + const DEFAULT_VISIBLE_MAINTAINERS = 5 // Show admin controls when connected (let npm CLI handle permission errors) @@ -141,18 +148,49 @@ async function handleAddOwner() { } } -async function handleRemoveOwner(username: string) { - const operation: NewOperation = { - type: 'owner:rm', - params: { - user: username, - pkg: props.packageName, - }, - description: `Remove @${username} from ${props.packageName}`, - command: `npm owner rm ${username} ${props.packageName}`, - } +// Open remove owner confirmation dialog +function openRemoveDialog(username: string) { + removeTarget.value = { username } + removeError.value = null + removeDialogRef.value?.showModal() +} + +// Close remove owner confirmation dialog +function closeRemoveDialog() { + removeDialogRef.value?.close() + removeTarget.value = null + removeError.value = null +} - await addOperation(operation) +// Remove owner (after confirmation) +async function handleRemoveOwner() { + if (!removeTarget.value) return + + isRemoving.value = true + removeError.value = null + + try { + const operation: NewOperation = { + type: 'owner:rm', + params: { + user: removeTarget.value.username, + pkg: props.packageName, + }, + description: `Remove @${removeTarget.value.username} from ${props.packageName}`, + command: `npm owner rm ${removeTarget.value.username} ${props.packageName}`, + } + + const result = await addOperation(operation) + if (result) { + closeRemoveDialog() + } else { + removeError.value = connectorError.value || 'Failed to queue remove operation' + } + } catch (err) { + removeError.value = err instanceof Error ? err.message : 'Failed to remove owner' + } finally { + isRemoving.value = false + } } // Load access info when connected and for scoped packages @@ -226,7 +264,7 @@ watch( name: maintainer.name, }) " - @click="handleRemoveOwner(maintainer.name)" + @click="openRemoveDialog(maintainer.name)" >