From b24d8b3d7d6b8c6f0db69055b92e55125f4977c7 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Wed, 28 Jan 2026 11:14:39 +0000 Subject: [PATCH 1/2] test: mock connector for e2e tests --- CONTRIBUTING.md | 50 ++ package.json | 1 + playwright.config.ts | 3 + pnpm-lock.yaml | 3 + shared/test-utils/index.ts | 24 + .../test-utils/mock-connector-composable.ts | 388 ++++++++++++ shared/test-utils/mock-connector-state.ts | 486 ++++++++++++++ shared/test-utils/mock-connector-types.ts | 74 +++ test/nuxt/components/ConnectorModal.spec.ts | 386 +++++++++++ tests/connector.spec.ts | 496 +++++++++++++++ tests/global-setup.ts | 25 + tests/global-teardown.ts | 24 + tests/helpers/fixtures.ts | 178 ++++++ tests/helpers/mock-connector-state.ts | 52 ++ tests/helpers/mock-connector.ts | 597 ++++++++++++++++++ 15 files changed, 2787 insertions(+) create mode 100644 shared/test-utils/index.ts create mode 100644 shared/test-utils/mock-connector-composable.ts create mode 100644 shared/test-utils/mock-connector-state.ts create mode 100644 shared/test-utils/mock-connector-types.ts create mode 100644 test/nuxt/components/ConnectorModal.spec.ts create mode 100644 tests/connector.spec.ts create mode 100644 tests/global-setup.ts create mode 100644 tests/global-teardown.ts create mode 100644 tests/helpers/fixtures.ts create mode 100644 tests/helpers/mock-connector-state.ts create mode 100644 tests/helpers/mock-connector.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1991476a..ac89ead5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -347,6 +347,56 @@ pnpm test:browser:ui # Run with Playwright UI Make sure to read about [Playwright best practices](https://playwright.dev/docs/best-practices) and don't rely on classes/IDs but try to follow user-replicable behaviour (like selecting an element based on text content instead). +### Testing connector features + +Features that require authentication through the local connector (org management, package collaborators, operations queue) are tested using a mock connector server. The testing infrastructure includes: + +**For Vitest component tests** (`test/nuxt/`): + +- Mock the `useConnector` composable with reactive state +- Use `document.body` queries for components using Teleport +- See `test/nuxt/components/ConnectorModal.spec.ts` for an example + +```typescript +// Create mock state +const mockState = ref({ connected: false, npmUser: null, ... }) + +// Mock the composable +vi.mock('~/composables/useConnector', () => ({ + useConnector: () => ({ + isConnected: computed(() => mockState.value.connected), + // ... other properties + }), +})) +``` + +**For Playwright E2E tests** (`tests/`): + +- A mock HTTP server (`tests/helpers/mock-connector.ts`) implements the connector API +- The server starts automatically via Playwright's global setup +- Use the `mockConnector` fixture to set up test data and the `gotoConnected` helper to navigate with authentication + +```typescript +test('shows org members', async ({ page, gotoConnected, mockConnector }) => { + // Set up test data + await mockConnector.setOrgData('@testorg', { + users: { testuser: 'owner', member1: 'admin' }, + }) + + // Navigate with connector authentication + await gotoConnected('/@testorg') + + // Test assertions + await expect(page.getByRole('link', { name: '@testuser' })).toBeVisible() +}) +``` + +The mock connector supports test endpoints for state manipulation: + +- `/__test__/org/:org` - Set org users and teams +- `/__test__/user/orgs` - Set user's organizations +- `/__test__/operations` - Get/manipulate operation queue + ## Submitting changes ### Before submitting diff --git a/package.json b/package.json index 95ff74f4..56892b37 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "@voidzero-dev/vite-plus-core": "latest", "@vue/test-utils": "2.4.6", "axe-core": "^4.11.1", + "h3-next": "npm:h3@2.0.1-rc.11", "happy-dom": "20.3.5", "lint-staged": "16.2.7", "marked": "17.0.1", diff --git a/playwright.config.ts b/playwright.config.ts index 39527d2b..80fc462f 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -11,6 +11,9 @@ export default defineConfig({ workers: process.env.CI ? 1 : undefined, reporter: 'html', timeout: 120_000, + // Start/stop mock connector server before/after all tests + globalSetup: fileURLToPath(new URL('./tests/global-setup.ts', import.meta.url)), + globalTeardown: fileURLToPath(new URL('./tests/global-teardown.ts', import.meta.url)), use: { trace: 'on-first-retry', nuxt: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 56716b3f..0dcabc6d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -159,6 +159,9 @@ importers: axe-core: specifier: ^4.11.1 version: 4.11.1 + h3-next: + specifier: npm:h3@2.0.1-rc.11 + version: h3@2.0.1-rc.11 happy-dom: specifier: 20.3.5 version: 20.3.5 diff --git a/shared/test-utils/index.ts b/shared/test-utils/index.ts new file mode 100644 index 00000000..5e8ee32d --- /dev/null +++ b/shared/test-utils/index.ts @@ -0,0 +1,24 @@ +/** + * Shared test utilities for mock connector testing. + * + * These utilities can be used by both: + * - Playwright E2E tests (via HTTP server) + * - Vitest browser tests (via composable mock) + */ + +// Types +export * from './mock-connector-types' + +// State management (used by both HTTP server and composable mock) +export { + MockConnectorStateManager, + createMockConnectorState, + DEFAULT_MOCK_CONFIG, +} from './mock-connector-state' + +// Composable mock (for Vitest browser tests) +export { + createMockConnectorComposable, + type MockConnectorComposable, + type MockConnectorTestControls, +} from './mock-connector-composable' diff --git a/shared/test-utils/mock-connector-composable.ts b/shared/test-utils/mock-connector-composable.ts new file mode 100644 index 00000000..b434c535 --- /dev/null +++ b/shared/test-utils/mock-connector-composable.ts @@ -0,0 +1,388 @@ +/** + * Mock implementation of useConnector for Vitest browser tests. + * + * This provides a fully functional mock that can be used with vi.mock() + * to test components that depend on the connector without needing an HTTP server. + */ + +import { ref, computed, readonly, type Ref, type ComputedRef } from 'vue' +import { + MockConnectorStateManager, + createMockConnectorState, + DEFAULT_MOCK_CONFIG, +} from './mock-connector-state' +import type { + MockConnectorConfig, + NewOperationInput, + PendingOperation, + OrgRole, + AccessLevel, +} from './mock-connector-types' + +export interface MockConnectorComposable { + // State + state: Readonly< + Ref<{ + connected: boolean + connecting: boolean + npmUser: string | null + avatar: string | null + operations: PendingOperation[] + error: string | null + lastExecutionTime: number | null + }> + > + + // Computed - connection + isConnected: ComputedRef + isConnecting: ComputedRef + npmUser: ComputedRef + avatar: ComputedRef + error: ComputedRef + lastExecutionTime: ComputedRef + + // Computed - operations + operations: ComputedRef + pendingOperations: ComputedRef + approvedOperations: ComputedRef + completedOperations: ComputedRef + activeOperations: ComputedRef + hasOperations: ComputedRef + hasPendingOperations: ComputedRef + hasApprovedOperations: ComputedRef + hasActiveOperations: ComputedRef + hasCompletedOperations: ComputedRef + + // Actions - connection + connect: (token: string, port?: number) => Promise + reconnect: () => Promise + disconnect: () => void + refreshState: () => Promise + + // Actions - operations + addOperation: (operation: NewOperationInput) => Promise + addOperations: (operations: NewOperationInput[]) => Promise + removeOperation: (id: string) => Promise + clearOperations: () => Promise + approveOperation: (id: string) => Promise + retryOperation: (id: string) => Promise + approveAll: () => Promise + executeOperations: (otp?: string) => Promise<{ success: boolean; otpRequired?: boolean }> + + // Actions - data fetching + listOrgUsers: (org: string) => Promise | null> + listOrgTeams: (org: string) => Promise + listTeamUsers: (scopeTeam: string) => Promise + listPackageCollaborators: (pkg: string) => Promise | null> + listUserPackages: () => Promise | null> + listUserOrgs: () => Promise +} + +export interface MockConnectorTestControls { + /** The underlying state manager for direct manipulation */ + stateManager: MockConnectorStateManager + + /** Set org data directly */ + setOrgData: ( + org: string, + data: { + users?: Record + teams?: string[] + teamMembers?: Record + }, + ) => void + + /** Set user orgs directly */ + setUserOrgs: (orgs: string[]) => void + + /** Set user packages directly */ + setUserPackages: (packages: Record) => void + + /** Set package data directly */ + setPackageData: (pkg: string, data: { collaborators?: Record }) => void + + /** Reset all state */ + reset: () => void + + /** Simulate a connection (for testing connected state) */ + simulateConnect: () => void + + /** Simulate a disconnection */ + simulateDisconnect: () => void + + /** Simulate an error */ + simulateError: (message: string) => void + + /** Clear error */ + clearError: () => void +} + +/** + * Creates a mock useConnector composable for testing. + * + * Returns both the composable (to be used by components) and + * test controls (for setting up test scenarios). + */ +export function createMockConnectorComposable(config: MockConnectorConfig = DEFAULT_MOCK_CONFIG): { + composable: () => MockConnectorComposable + controls: MockConnectorTestControls +} { + const stateManager = new MockConnectorStateManager(createMockConnectorState(config)) + + // Reactive state that mirrors the real composable + const state = 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, + }) + + // Helper to sync state from the state manager + const syncState = () => { + state.value = { + ...state.value, + connected: stateManager.isConnected(), + npmUser: stateManager.isConnected() ? stateManager.config.npmUser : null, + avatar: stateManager.isConnected() ? (stateManager.config.avatar ?? null) : null, + operations: [...stateManager.getOperations()], + } + } + + // The composable function that components will use + const composable = (): MockConnectorComposable => { + // Computed helpers for operations + const pendingOperations = computed(() => + state.value.operations.filter(op => op.status === 'pending'), + ) + const approvedOperations = computed(() => + state.value.operations.filter(op => op.status === 'approved'), + ) + const completedOperations = computed(() => + state.value.operations.filter( + op => op.status === 'completed' || (op.status === 'failed' && !op.result?.requiresOtp), + ), + ) + const activeOperations = computed(() => + state.value.operations.filter( + op => + op.status === 'pending' || + op.status === 'approved' || + op.status === 'running' || + (op.status === 'failed' && op.result?.requiresOtp), + ), + ) + + return { + // State - cast to satisfy the interface while keeping the readonly wrapper + state: readonly(state) as unknown as MockConnectorComposable['state'], + + // Computed - connection + isConnected: computed(() => state.value.connected), + isConnecting: computed(() => state.value.connecting), + npmUser: computed(() => state.value.npmUser), + avatar: computed(() => state.value.avatar), + error: computed(() => state.value.error), + lastExecutionTime: computed(() => state.value.lastExecutionTime), + + // Computed - operations + operations: computed(() => state.value.operations), + pendingOperations, + approvedOperations, + completedOperations, + activeOperations, + hasOperations: computed(() => state.value.operations.length > 0), + hasPendingOperations: computed(() => pendingOperations.value.length > 0), + hasApprovedOperations: computed(() => approvedOperations.value.length > 0), + hasActiveOperations: computed(() => activeOperations.value.length > 0), + hasCompletedOperations: computed(() => completedOperations.value.length > 0), + + // Actions - connection + async connect(token: string, _port?: number): Promise { + state.value.connecting = true + state.value.error = null + + // Simulate async delay + await new Promise(resolve => setTimeout(resolve, 10)) + + const success = stateManager.connect(token) + if (success) { + syncState() + } else { + state.value.error = 'Invalid token' + } + + state.value.connecting = false + return success + }, + + async reconnect(): Promise { + if (!stateManager.isConnected()) return false + return true + }, + + disconnect(): void { + stateManager.disconnect() + syncState() + state.value.error = null + }, + + async refreshState(): Promise { + syncState() + }, + + // Actions - operations + async addOperation(operation: NewOperationInput): Promise { + if (!stateManager.isConnected()) return null + const op = stateManager.addOperation(operation) + syncState() + return op + }, + + async addOperations(operations: NewOperationInput[]): Promise { + if (!stateManager.isConnected()) return [] + const ops = stateManager.addOperations(operations) + syncState() + return ops + }, + + async removeOperation(id: string): Promise { + if (!stateManager.isConnected()) return false + const success = stateManager.removeOperation(id) + syncState() + return success + }, + + async clearOperations(): Promise { + if (!stateManager.isConnected()) return 0 + const count = stateManager.clearOperations() + syncState() + return count + }, + + async approveOperation(id: string): Promise { + if (!stateManager.isConnected()) return false + const op = stateManager.approveOperation(id) + syncState() + return op !== null + }, + + async retryOperation(id: string): Promise { + if (!stateManager.isConnected()) return false + const op = stateManager.retryOperation(id) + syncState() + return op !== null + }, + + async approveAll(): Promise { + if (!stateManager.isConnected()) return 0 + const count = stateManager.approveAll() + syncState() + return count + }, + + async executeOperations(otp?: string): Promise<{ success: boolean; otpRequired?: boolean }> { + if (!stateManager.isConnected()) return { success: false } + const result = stateManager.executeOperations({ otp }) + syncState() + state.value.lastExecutionTime = Date.now() + return { + success: true, + otpRequired: result.otpRequired, + } + }, + + // Actions - data fetching + async listOrgUsers(org: string): Promise | null> { + if (!stateManager.isConnected()) return null + return stateManager.getOrgUsers(org) + }, + + async listOrgTeams(org: string): Promise { + if (!stateManager.isConnected()) return null + return stateManager.getOrgTeams(org) + }, + + async listTeamUsers(scopeTeam: string): Promise { + if (!stateManager.isConnected()) return null + const [scope, team] = scopeTeam.split(':') + if (!scope || !team) return null + return stateManager.getTeamUsers(scope, team) + }, + + async listPackageCollaborators(pkg: string): Promise | null> { + if (!stateManager.isConnected()) return null + return stateManager.getPackageCollaborators(pkg) + }, + + async listUserPackages(): Promise | null> { + if (!stateManager.isConnected()) return null + return stateManager.getUserPackages() + }, + + async listUserOrgs(): Promise { + if (!stateManager.isConnected()) return null + return stateManager.getUserOrgs() + }, + } + } + + // Test controls for setting up scenarios + const controls: MockConnectorTestControls = { + stateManager, + + setOrgData(org, data) { + stateManager.setOrgData(org, data) + }, + + setUserOrgs(orgs) { + stateManager.setUserOrgs(orgs) + }, + + setUserPackages(packages) { + stateManager.setUserPackages(packages) + }, + + setPackageData(pkg, data) { + stateManager.setPackageData(pkg, { collaborators: data.collaborators ?? {} }) + }, + + reset() { + stateManager.reset() + state.value = { + connected: false, + connecting: false, + npmUser: null, + avatar: null, + operations: [], + error: null, + lastExecutionTime: null, + } + }, + + simulateConnect() { + stateManager.connect(config.token) + syncState() + }, + + simulateDisconnect() { + stateManager.disconnect() + syncState() + }, + + simulateError(message: string) { + state.value.error = message + }, + + clearError() { + state.value.error = null + }, + } + + return { composable, controls } +} + +// Export types +export type { MockConnectorConfig, PendingOperation, OrgRole, AccessLevel } diff --git a/shared/test-utils/mock-connector-state.ts b/shared/test-utils/mock-connector-state.ts new file mode 100644 index 00000000..7ce2cc37 --- /dev/null +++ b/shared/test-utils/mock-connector-state.ts @@ -0,0 +1,486 @@ +/** + * Core state management for the mock connector. + * This can be used by both the HTTP server (E2E tests) and + * the composable mock (Vitest browser tests). + */ + +import type { + MockConnectorConfig, + MockConnectorStateData, + MockOrgData, + MockPackageData, + NewOperationInput, + ExecuteOptions, + ExecuteResult, + OrgRole, + AccessLevel, + PendingOperation, + OperationResult, +} from './mock-connector-types' + +/** + * Creates a new mock connector state with default values. + */ +export function createMockConnectorState(config: MockConnectorConfig): MockConnectorStateData { + return { + config: { + port: 31415, + avatar: null, + ...config, + }, + connected: false, + connectedAt: null, + orgs: {}, + packages: {}, + userPackages: {}, + userOrgs: [], + operations: [], + operationIdCounter: 0, + } +} + +/** + * State manipulation class for the mock connector. + * This is the core logic shared between HTTP server and composable mock. + */ +export class MockConnectorStateManager { + public state: MockConnectorStateData + + constructor(initialState: MockConnectorStateData) { + this.state = initialState + } + + // ============ Configuration ============ + + get config(): MockConnectorConfig { + return this.state.config + } + + get token(): string { + return this.state.config.token + } + + get port(): number { + return this.state.config.port ?? 31415 + } + + // ============ Connection ============ + + connect(token: string): boolean { + if (token !== this.state.config.token) { + return false + } + this.state.connected = true + this.state.connectedAt = Date.now() + return true + } + + disconnect(): void { + this.state.connected = false + this.state.connectedAt = null + this.state.operations = [] + } + + isConnected(): boolean { + return this.state.connected + } + + // ============ Org Data ============ + + setOrgData(org: string, data: Partial): void { + const existing = this.state.orgs[org] ?? { users: {}, teams: [], teamMembers: {} } + this.state.orgs[org] = { + users: { ...existing.users, ...data.users }, + teams: data.teams ?? existing.teams, + teamMembers: { ...existing.teamMembers, ...data.teamMembers }, + } + } + + getOrgUsers(org: string): Record | null { + // Normalize: handle with or without @ prefix + const normalizedOrg = org.startsWith('@') ? org : `@${org}` + return this.state.orgs[normalizedOrg]?.users ?? null + } + + getOrgTeams(org: string): string[] | null { + const normalizedOrg = org.startsWith('@') ? org : `@${org}` + return this.state.orgs[normalizedOrg]?.teams ?? null + } + + getTeamUsers(scope: string, team: string): string[] | null { + // scope should be like "@org" or "org" + const normalizedScope = scope.startsWith('@') ? scope : `@${scope}` + const org = this.state.orgs[normalizedScope] + if (!org) return null + return org.teamMembers[team] ?? null + } + + // ============ Package Data ============ + + setPackageData(pkg: string, data: MockPackageData): void { + this.state.packages[pkg] = data + } + + getPackageCollaborators(pkg: string): Record | null { + return this.state.packages[pkg]?.collaborators ?? null + } + + // ============ User Data ============ + + setUserPackages(packages: Record): void { + this.state.userPackages = packages + } + + setUserOrgs(orgs: string[]): void { + this.state.userOrgs = orgs + } + + getUserPackages(): Record { + return this.state.userPackages + } + + getUserOrgs(): string[] { + return this.state.userOrgs + } + + // ============ Operations Queue ============ + + addOperation(operation: NewOperationInput): PendingOperation { + const id = `op-${++this.state.operationIdCounter}` + const newOp: PendingOperation = { + id, + type: operation.type, + params: operation.params, + description: operation.description, + command: operation.command, + status: 'pending', + createdAt: Date.now(), + dependsOn: operation.dependsOn, + } + this.state.operations.push(newOp) + return newOp + } + + addOperations(operations: NewOperationInput[]): PendingOperation[] { + return operations.map(op => this.addOperation(op)) + } + + getOperation(id: string): PendingOperation | undefined { + return this.state.operations.find(op => op.id === id) + } + + getOperations(): PendingOperation[] { + return this.state.operations + } + + removeOperation(id: string): boolean { + const index = this.state.operations.findIndex(op => op.id === id) + if (index === -1) return false + const op = this.state.operations[index] + // Can't remove running operations + if (op?.status === 'running') return false + this.state.operations.splice(index, 1) + return true + } + + clearOperations(): number { + const removable = this.state.operations.filter(op => op.status !== 'running') + const count = removable.length + this.state.operations = this.state.operations.filter(op => op.status === 'running') + return count + } + + approveOperation(id: string): PendingOperation | null { + const op = this.state.operations.find(op => op.id === id) + if (!op || op.status !== 'pending') return null + op.status = 'approved' + return op + } + + approveAll(): number { + let count = 0 + for (const op of this.state.operations) { + if (op.status === 'pending') { + op.status = 'approved' + count++ + } + } + return count + } + + retryOperation(id: string): PendingOperation | null { + const op = this.state.operations.find(op => op.id === id) + if (!op || op.status !== 'failed') return null + op.status = 'approved' + op.result = undefined + return op + } + + /** + * Executes all approved operations. + * In the mock, this transitions them to completed status. + */ + executeOperations(options?: ExecuteOptions): ExecuteResult { + const results: OperationResult[] = [] + const approved = this.state.operations.filter(op => op.status === 'approved') + + // Sort by dependencies + const sorted = this.sortByDependencies(approved) + + for (const op of sorted) { + // Check if dependent operation completed successfully + if (op.dependsOn) { + const dep = this.state.operations.find(d => d.id === op.dependsOn) + if (!dep || dep.status !== 'completed') { + // Skip - dependency not met + continue + } + } + + op.status = 'running' + + // Check for configured result + const configuredResult = options?.results?.[op.id] + if (configuredResult) { + const result: OperationResult = { + stdout: configuredResult.stdout ?? '', + stderr: configuredResult.stderr ?? '', + exitCode: configuredResult.exitCode ?? 1, + requiresOtp: configuredResult.requiresOtp, + authFailure: configuredResult.authFailure, + } + op.result = result + op.status = result.exitCode === 0 ? 'completed' : 'failed' + results.push(result) + + if (result.requiresOtp && !options?.otp) { + return { results, otpRequired: true } + } + } else { + // Default: success + const result: OperationResult = { + stdout: `Mock: ${op.command}`, + stderr: '', + exitCode: 0, + } + op.result = result + op.status = 'completed' + results.push(result) + + // Apply the operation's effects to mock state + this.applyOperationEffect(op) + } + } + + return { results } + } + + /** + * Applies the side effects of a successful operation to the mock state. + */ + private applyOperationEffect(op: PendingOperation): void { + const { type, params } = op + + switch (type) { + case 'org:add-user': { + const org = params['org'] + const user = params['user'] + const role = (params['role'] as OrgRole) ?? 'developer' + if (org && user) { + const normalizedOrg = org.startsWith('@') ? org : `@${org}` + if (!this.state.orgs[normalizedOrg]) { + this.state.orgs[normalizedOrg] = { users: {}, teams: [], teamMembers: {} } + } + this.state.orgs[normalizedOrg].users[user] = role + } + break + } + case 'org:rm-user': { + const org = params['org'] + const user = params['user'] + if (org && user) { + const normalizedOrg = org.startsWith('@') ? org : `@${org}` + if (this.state.orgs[normalizedOrg]) { + delete this.state.orgs[normalizedOrg].users[user] + } + } + break + } + case 'org:set-role': { + const org = params['org'] + const user = params['user'] + const role = params['role'] as OrgRole + if (org && user && role) { + const normalizedOrg = org.startsWith('@') ? org : `@${org}` + if (this.state.orgs[normalizedOrg]) { + this.state.orgs[normalizedOrg].users[user] = role + } + } + break + } + case 'team:create': { + const org = params['org'] + const team = params['team'] + if (org && team) { + const normalizedOrg = org.startsWith('@') ? org : `@${org}` + if (!this.state.orgs[normalizedOrg]) { + this.state.orgs[normalizedOrg] = { users: {}, teams: [], teamMembers: {} } + } + if (!this.state.orgs[normalizedOrg].teams.includes(team)) { + this.state.orgs[normalizedOrg].teams.push(team) + } + this.state.orgs[normalizedOrg].teamMembers[team] = [] + } + break + } + case 'team:destroy': { + const org = params['org'] + const team = params['team'] + if (org && team) { + const normalizedOrg = org.startsWith('@') ? org : `@${org}` + if (this.state.orgs[normalizedOrg]) { + this.state.orgs[normalizedOrg].teams = this.state.orgs[normalizedOrg].teams.filter( + t => t !== team, + ) + delete this.state.orgs[normalizedOrg].teamMembers[team] + } + } + break + } + case 'team:add-user': { + const scopeTeam = params['scopeTeam'] + const user = params['user'] + if (scopeTeam && user) { + const [scope, team] = scopeTeam.split(':') + if (scope && team) { + const normalizedScope = scope.startsWith('@') ? scope : `@${scope}` + if (this.state.orgs[normalizedScope]) { + const members = this.state.orgs[normalizedScope].teamMembers[team] ?? [] + if (!members.includes(user)) { + members.push(user) + } + this.state.orgs[normalizedScope].teamMembers[team] = members + } + } + } + break + } + case 'team:rm-user': { + const scopeTeam = params['scopeTeam'] + const user = params['user'] + if (scopeTeam && user) { + const [scope, team] = scopeTeam.split(':') + if (scope && team) { + const normalizedScope = scope.startsWith('@') ? scope : `@${scope}` + if (this.state.orgs[normalizedScope]) { + const members = this.state.orgs[normalizedScope].teamMembers[team] + if (members) { + this.state.orgs[normalizedScope].teamMembers[team] = members.filter(u => u !== user) + } + } + } + } + break + } + case 'access:grant': { + const pkg = params['package'] + const user = params['user'] + const level = (params['level'] as AccessLevel) ?? 'read-write' + if (pkg && user) { + if (!this.state.packages[pkg]) { + this.state.packages[pkg] = { collaborators: {} } + } + this.state.packages[pkg].collaborators[user] = level + } + break + } + case 'access:revoke': { + const pkg = params['package'] + const user = params['user'] + if (pkg && user && this.state.packages[pkg]) { + delete this.state.packages[pkg].collaborators[user] + } + break + } + case 'owner:add': { + const pkg = params['package'] + const user = params['user'] + if (pkg && user) { + if (!this.state.packages[pkg]) { + this.state.packages[pkg] = { collaborators: {} } + } + this.state.packages[pkg].collaborators[user] = 'read-write' + } + break + } + case 'owner:rm': { + const pkg = params['package'] + const user = params['user'] + if (pkg && user && this.state.packages[pkg]) { + delete this.state.packages[pkg].collaborators[user] + } + break + } + case 'package:init': { + const pkg = params['package'] + if (pkg) { + this.state.packages[pkg] = { + collaborators: { [this.state.config.npmUser]: 'read-write' }, + } + this.state.userPackages[pkg] = 'read-write' + } + break + } + } + } + + /** + * Sort operations by dependencies (topological sort). + */ + private sortByDependencies(operations: PendingOperation[]): PendingOperation[] { + const result: PendingOperation[] = [] + const visited = new Set() + + const visit = (op: PendingOperation) => { + if (visited.has(op.id)) return + visited.add(op.id) + + if (op.dependsOn) { + const dep = operations.find(d => d.id === op.dependsOn) + if (dep) visit(dep) + } + + result.push(op) + } + + for (const op of operations) { + visit(op) + } + + return result + } + + // ============ Reset ============ + + /** + * Resets the state to initial values while keeping the config. + */ + reset(): void { + this.state.connected = false + this.state.connectedAt = null + this.state.orgs = {} + this.state.packages = {} + this.state.userPackages = {} + this.state.userOrgs = [] + this.state.operations = [] + this.state.operationIdCounter = 0 + } +} + +/** Default test configuration */ +export const DEFAULT_MOCK_CONFIG: MockConnectorConfig = { + token: 'test-token-e2e-12345', + npmUser: 'testuser', + avatar: null, + port: 31415, +} diff --git a/shared/test-utils/mock-connector-types.ts b/shared/test-utils/mock-connector-types.ts new file mode 100644 index 00000000..ddf14540 --- /dev/null +++ b/shared/test-utils/mock-connector-types.ts @@ -0,0 +1,74 @@ +/** + * Shared types for the mock connector used in both E2E and unit tests. + */ + +import type { PendingOperation, OperationType, OperationResult } from '../../cli/src/types' + +export type OrgRole = 'developer' | 'admin' | 'owner' +export type AccessLevel = 'read-only' | 'read-write' + +export interface MockConnectorConfig { + /** The token required for authentication */ + token: string + /** The simulated npm username */ + npmUser: string + /** Optional avatar (base64 data URL) */ + avatar?: string | null + /** Port to run the mock server on (default: 31415) */ + port?: number +} + +export interface MockOrgData { + /** Members and their roles */ + users: Record + /** Team names */ + teams: string[] + /** Team memberships: team name -> list of usernames */ + teamMembers: Record +} + +export interface MockPackageData { + /** Collaborators and their access levels */ + collaborators: Record +} + +export interface MockConnectorStateData { + // Configuration + config: MockConnectorConfig + + // Session state + connected: boolean + connectedAt: number | null + + // Mock data + orgs: Record + packages: Record + userPackages: Record + userOrgs: string[] + + // Operations queue + operations: PendingOperation[] + operationIdCounter: number +} + +export interface NewOperationInput { + type: OperationType + params: Record + description: string + command: string + dependsOn?: string +} + +export interface ExecuteOptions { + otp?: string + /** Map of operation IDs to their results (for testing failures) */ + results?: Record> +} + +export interface ExecuteResult { + results: OperationResult[] + otpRequired?: boolean +} + +/** Re-export types for convenience */ +export type { PendingOperation, OperationType, OperationResult } diff --git a/test/nuxt/components/ConnectorModal.spec.ts b/test/nuxt/components/ConnectorModal.spec.ts new file mode 100644 index 00000000..c9079d83 --- /dev/null +++ b/test/nuxt/components/ConnectorModal.spec.ts @@ -0,0 +1,386 @@ +/** + * Tests for ConnectorModal component. + * + * Uses the mock connector composable to test various states + * without needing an actual HTTP server. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mountSuspended } from '@nuxt/test-utils/runtime' +import { ref, computed, readonly, nextTick } from 'vue' +import type { VueWrapper } from '@vue/test-utils' +import type { MockConnectorTestControls } from '../../../shared/test-utils' +import { ConnectorModal } 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 Array<{ id: string; status: string }>, + error: null as string | null, + lastExecutionTime: null as number | null, +}) + +// 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), + pendingOperations: computed(() => + mockState.value.operations.filter(op => op.status === 'pending'), + ), + approvedOperations: computed(() => + mockState.value.operations.filter(op => op.status === 'approved'), + ), + completedOperations: computed(() => + mockState.value.operations.filter(op => op.status === 'completed'), + ), + activeOperations: computed(() => + mockState.value.operations.filter(op => op.status !== 'completed'), + ), + hasOperations: computed(() => mockState.value.operations.length > 0), + hasPendingOperations: computed(() => + mockState.value.operations.some(op => op.status === 'pending'), + ), + hasApprovedOperations: computed(() => + mockState.value.operations.some(op => op.status === 'approved'), + ), + hasActiveOperations: computed(() => + mockState.value.operations.some(op => op.status !== 'completed'), + ), + hasCompletedOperations: computed(() => + mockState.value.operations.some(op => op.status === 'completed'), + ), + connect: vi.fn().mockResolvedValue(true), + reconnect: vi.fn().mockResolvedValue(true), + disconnect: vi.fn(), + refreshState: vi.fn().mockResolvedValue(undefined), + addOperation: vi.fn().mockResolvedValue(null), + 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: vi.fn().mockResolvedValue(null), + listPackageCollaborators: vi.fn().mockResolvedValue(null), + listUserPackages: vi.fn().mockResolvedValue(null), + listUserOrgs: vi.fn().mockResolvedValue(null), + } +} + +// Test controls for manipulating mock state +const mockControls: MockConnectorTestControls = { + stateManager: null as unknown as MockConnectorTestControls['stateManager'], + setOrgData: vi.fn(), + setUserOrgs: vi.fn(), + setUserPackages: vi.fn(), + setPackageData: vi.fn(), + reset() { + mockState.value = { + connected: false, + connecting: false, + npmUser: null, + avatar: null, + operations: [], + error: null, + lastExecutionTime: null, + } + }, + simulateConnect() { + mockState.value.connected = true + mockState.value.npmUser = 'testuser' + mockState.value.avatar = 'https://example.com/avatar.png' + }, + simulateDisconnect() { + mockState.value.connected = false + mockState.value.npmUser = null + mockState.value.avatar = null + }, + simulateError(message: string) { + mockState.value.error = message + }, + clearError() { + mockState.value.error = null + }, +} + +// Mock the composables at module level (vi.mock is hoisted) +vi.mock('~/composables/useConnector', () => ({ + useConnector: createMockUseConnector, +})) + +vi.mock('~/composables/useSelectedPackageManager', () => ({ + useSelectedPackageManager: () => ref('npm'), +})) + +vi.mock('~/utils/npm', () => ({ + getExecuteCommand: () => 'npx npmx-connector', +})) + +// Mock clipboard +const mockWriteText = vi.fn().mockResolvedValue(undefined) +vi.stubGlobal('navigator', { + ...navigator, + clipboard: { + writeText: mockWriteText, + readText: vi.fn().mockResolvedValue(''), + }, +}) + +// Track current wrapper for cleanup +let currentWrapper: VueWrapper | null = null + +/** + * Get the modal dialog element from the document body (where Teleport sends it) + */ +function getModalDialog(): HTMLElement | null { + return document.body.querySelector('[role="dialog"]') +} + +// Reset state before each test +beforeEach(() => { + mockControls.reset() + mockWriteText.mockClear() +}) + +afterEach(() => { + vi.clearAllMocks() + // Clean up Vue wrapper to remove teleported content + if (currentWrapper) { + currentWrapper.unmount() + currentWrapper = null + } +}) + +describe('ConnectorModal', () => { + describe('Disconnected state', () => { + it('shows connection form when not connected', async () => { + currentWrapper = await mountSuspended(ConnectorModal, { + props: { open: true }, + attachTo: document.body, + }) + await nextTick() + + const dialog = getModalDialog() + expect(dialog).not.toBeNull() + + // Should show the form (disconnected state) + const form = dialog?.querySelector('form') + expect(form).not.toBeNull() + + // Should show token input + const tokenInput = dialog?.querySelector('input[name="connector-token"]') + expect(tokenInput).not.toBeNull() + + // Should show connect button + const connectButton = dialog?.querySelector('button[type="submit"]') + expect(connectButton).not.toBeNull() + }) + + it('shows the CLI command to run', async () => { + currentWrapper = await mountSuspended(ConnectorModal, { + props: { open: true }, + attachTo: document.body, + }) + await nextTick() + + const dialog = getModalDialog() + expect(dialog?.textContent).toContain('npx npmx-connector') + }) + + it('can copy command to clipboard', async () => { + currentWrapper = await mountSuspended(ConnectorModal, { + props: { open: true }, + attachTo: document.body, + }) + await nextTick() + + const dialog = getModalDialog() + const copyButton = dialog?.querySelector( + 'button[aria-label="Copy command"]', + ) as HTMLButtonElement + expect(copyButton).not.toBeNull() + + copyButton?.click() + await nextTick() + + expect(mockWriteText).toHaveBeenCalled() + }) + + it('disables connect button when token is empty', async () => { + currentWrapper = await mountSuspended(ConnectorModal, { + props: { open: true }, + attachTo: document.body, + }) + await nextTick() + + const dialog = getModalDialog() + const connectButton = dialog?.querySelector('button[type="submit"]') as HTMLButtonElement + expect(connectButton?.disabled).toBe(true) + }) + + it('enables connect button when token is entered', async () => { + currentWrapper = await mountSuspended(ConnectorModal, { + props: { open: true }, + attachTo: document.body, + }) + await nextTick() + + const dialog = getModalDialog() + const tokenInput = dialog?.querySelector('input[name="connector-token"]') as HTMLInputElement + expect(tokenInput).not.toBeNull() + + // Set value and dispatch input event to trigger v-model + tokenInput.value = 'my-test-token' + tokenInput.dispatchEvent(new Event('input', { bubbles: true })) + await nextTick() + + const connectButton = dialog?.querySelector('button[type="submit"]') as HTMLButtonElement + expect(connectButton?.disabled).toBe(false) + }) + + it('shows error message when connection fails', async () => { + // Simulate an error before mounting + mockControls.simulateError('Could not reach connector. Is it running?') + + currentWrapper = await mountSuspended(ConnectorModal, { + props: { open: true }, + attachTo: document.body, + }) + await nextTick() + + const dialog = getModalDialog() + const alerts = dialog?.querySelectorAll('[role="alert"]') + // Find the alert containing our error message + const errorAlert = Array.from(alerts || []).find(el => + el.textContent?.includes('Could not reach connector'), + ) + expect(errorAlert).not.toBeUndefined() + }) + }) + + describe('Connected state', () => { + beforeEach(() => { + // Start in connected state + mockControls.simulateConnect() + }) + + it('shows connected status', async () => { + currentWrapper = await mountSuspended(ConnectorModal, { + props: { open: true }, + attachTo: document.body, + }) + await nextTick() + + const dialog = getModalDialog() + expect(dialog?.textContent).toContain('Connected') + }) + + it('shows logged in username', async () => { + currentWrapper = await mountSuspended(ConnectorModal, { + props: { open: true }, + attachTo: document.body, + }) + await nextTick() + + const dialog = getModalDialog() + expect(dialog?.textContent).toContain('testuser') + }) + + it('shows disconnect button', async () => { + currentWrapper = await mountSuspended(ConnectorModal, { + props: { open: true }, + attachTo: document.body, + }) + await nextTick() + + const dialog = getModalDialog() + const buttons = dialog?.querySelectorAll('button') + const disconnectBtn = Array.from(buttons || []).find(b => + b.textContent?.toLowerCase().includes('disconnect'), + ) + expect(disconnectBtn).not.toBeUndefined() + }) + + it('hides connection form when connected', async () => { + currentWrapper = await mountSuspended(ConnectorModal, { + props: { open: true }, + attachTo: document.body, + }) + await nextTick() + + const dialog = getModalDialog() + // Form and token input should not exist when connected + const form = dialog?.querySelector('form') + expect(form).toBeNull() + }) + }) + + describe('Modal behavior', () => { + it('closes modal when close button is clicked', async () => { + currentWrapper = await mountSuspended(ConnectorModal, { + props: { open: true }, + attachTo: document.body, + }) + await nextTick() + + const dialog = getModalDialog() + // Find the close button (X icon) within the dialog header + const closeBtn = dialog?.querySelector('button[aria-label="Close"]') as HTMLButtonElement + expect(closeBtn).not.toBeNull() + + closeBtn?.click() + await nextTick() + + // Check that open was set to false (v-model) + const emitted = currentWrapper.emitted('update:open') + expect(emitted).toBeTruthy() + expect(emitted![0]).toEqual([false]) + }) + + it('closes modal when backdrop is clicked', async () => { + currentWrapper = await mountSuspended(ConnectorModal, { + props: { open: true }, + attachTo: document.body, + }) + await nextTick() + + // Find the backdrop button by aria-label + const backdrop = document.body.querySelector( + 'button[aria-label="Close modal"]', + ) as HTMLButtonElement + expect(backdrop).not.toBeNull() + + backdrop?.click() + await nextTick() + + // Check that open was set to false (v-model) + const emitted = currentWrapper.emitted('update:open') + expect(emitted).toBeTruthy() + expect(emitted![0]).toEqual([false]) + }) + + it('does not render dialog when open is false', async () => { + currentWrapper = await mountSuspended(ConnectorModal, { + props: { open: false }, + attachTo: document.body, + }) + await nextTick() + + const dialog = getModalDialog() + expect(dialog).toBeNull() + }) + }) +}) diff --git a/tests/connector.spec.ts b/tests/connector.spec.ts new file mode 100644 index 00000000..b1f28b84 --- /dev/null +++ b/tests/connector.spec.ts @@ -0,0 +1,496 @@ +/** + * E2E tests for connector-authenticated features. + * + * These tests use a mock connector server (started in global setup) + * to test features that require being logged in via the connector. + */ + +import { test, expect } from './helpers/fixtures' + +test.describe('Connector Connection', () => { + test('connects via URL params and shows connected state', async ({ + page, + gotoConnected, + mockConnector, + }) => { + // Set up mock state + await mockConnector.setUserOrgs(['@testorg']) + + // Navigate with credentials in URL params + await gotoConnected('/') + + // Should show connected indicator + // The connector status shows a green dot or avatar when connected + // Look for the username link that appears when connected + await expect(page.getByRole('link', { name: '@testuser' })).toBeVisible({ timeout: 10000 }) + }) + + test('shows tooltip when hovering connector status', async ({ page, gotoConnected }) => { + await gotoConnected('/') + + // Hover over the connector button (look for the button with aria-label containing "connected") + const connectorButton = page.getByRole('button', { name: /connected/i }) + await connectorButton.hover() + + // Should show tooltip + await expect(page.getByRole('tooltip')).toContainText(/connected/i) + }) + + test('opens connector modal when clicking status button', async ({ page, gotoConnected }) => { + await gotoConnected('/') + + // Click the connector status button + await page.getByRole('button', { name: /connected/i }).click() + + // Should open the connector modal + // The modal should show the connected user + await expect(page.getByRole('dialog')).toBeVisible() + await expect(page.getByRole('dialog')).toContainText('testuser') + }) + + test('can disconnect from the connector', async ({ page, gotoConnected }) => { + await gotoConnected('/') + + // Open connector modal + await page.getByRole('button', { name: /connected/i }).click() + + // Should show modal with disconnect button + const modal = page.getByRole('dialog') + await expect(modal).toBeVisible() + + // Click disconnect button + await modal.getByRole('button', { name: /disconnect/i }).click() + + // Modal shows the disconnected state - close it manually + await modal.getByRole('button', { name: /close/i }).click() + + // Should show "click to connect" state - the button aria-label changes + await expect(page.getByRole('button', { name: /click to connect/i })).toBeVisible({ + timeout: 5000, + }) + }) +}) + +test.describe('Organization Management', () => { + test.beforeEach(async ({ mockConnector }) => { + // Set up mock org data + await mockConnector.setOrgData('@testorg', { + users: { + testuser: 'owner', + member1: 'admin', + member2: 'developer', + }, + teams: ['core', 'docs'], + teamMembers: { + core: ['testuser', 'member1'], + docs: ['member2'], + }, + }) + await mockConnector.setUserOrgs(['@testorg']) + }) + + test('shows org members when connected', async ({ page, gotoConnected }) => { + // Navigate to org page with connection + await gotoConnected('/@testorg') + + // Should show the members panel + const membersSection = page.locator('section[aria-labelledby="members-heading"]') + await expect(membersSection).toBeVisible({ timeout: 10000 }) + + // Should show all members + await expect(membersSection.getByRole('link', { name: '@testuser' })).toBeVisible({ + timeout: 10000, + }) + await expect(membersSection.getByRole('link', { name: '@member1' })).toBeVisible() + await expect(membersSection.getByRole('link', { name: '@member2' })).toBeVisible() + + // Should show role badges (the actual badges, not filter buttons or options) + await expect(membersSection.locator('span.px-1\\.5', { hasText: 'owner' })).toBeVisible() + await expect(membersSection.locator('span.px-1\\.5', { hasText: 'admin' })).toBeVisible() + await expect(membersSection.locator('span.px-1\\.5', { hasText: 'developer' })).toBeVisible() + }) + + test('can filter members by role', async ({ page, gotoConnected }) => { + await gotoConnected('/@testorg') + + const membersSection = page.locator('section[aria-labelledby="members-heading"]') + await expect(membersSection).toBeVisible() + + // Click the "admin" filter button + await membersSection.getByRole('button', { name: /admin/i }).click() + + // Should only show admin member + await expect(membersSection.getByRole('link', { name: '@member1' })).toBeVisible() + await expect(membersSection.getByRole('link', { name: '@testuser' })).not.toBeVisible() + await expect(membersSection.getByRole('link', { name: '@member2' })).not.toBeVisible() + }) + + test('can search members by name', async ({ page, gotoConnected }) => { + await gotoConnected('/@testorg') + + const membersSection = page.locator('section[aria-labelledby="members-heading"]') + await expect(membersSection).toBeVisible() + + // Type in the search input + const searchInput = membersSection.getByRole('searchbox') + await searchInput.fill('member1') + + // Should only show matching member + await expect(membersSection.getByRole('link', { name: '@member1' })).toBeVisible() + await expect(membersSection.getByRole('link', { name: '@testuser' })).not.toBeVisible() + await expect(membersSection.getByRole('link', { name: '@member2' })).not.toBeVisible() + }) + + test('can add a new member operation', async ({ page, gotoConnected, mockConnector }) => { + await gotoConnected('/@testorg') + + const membersSection = page.locator('section[aria-labelledby="members-heading"]') + await expect(membersSection).toBeVisible({ timeout: 10000 }) + + // Click "Add member" button (text is "+ Add member") + await membersSection.getByRole('button', { name: /add member/i }).click() + + // Fill in the form - use the input's name attribute + const usernameInput = membersSection.locator('input[name="new-member-username"]') + await usernameInput.fill('newuser') + + // Select role (admin) + await membersSection.locator('select[name="new-member-role"]').selectOption('admin') + + // Submit the form - button text is "add" + await membersSection.getByRole('button', { name: /^add$/i }).click() + + // Wait a moment for the operation to be added + await page.waitForTimeout(500) + + // Should have added an operation + const operations = await mockConnector.getOperations() + expect(operations).toHaveLength(1) + expect(operations[0]?.type).toBe('org:add-user') + // Note: The app may strip the @ prefix from org names + expect(operations[0]?.params.user).toBe('newuser') + expect(operations[0]?.params.role).toBe('admin') + }) + + test('can remove a member (adds operation)', async ({ page, gotoConnected, mockConnector }) => { + await gotoConnected('/@testorg') + + const membersSection = page.locator('section[aria-labelledby="members-heading"]') + await expect(membersSection).toBeVisible({ timeout: 10000 }) + + // Find the remove button for member2 - aria-label is "Remove member2 from org" + await membersSection.getByRole('button', { name: /remove member2/i }).click() + + // Wait a moment for the operation to be added + await page.waitForTimeout(500) + + // Should have added a remove operation + const operations = await mockConnector.getOperations() + expect(operations).toHaveLength(1) + expect(operations[0]?.type).toBe('org:rm-user') + // Note: The app may strip the @ prefix from org names + expect(operations[0]?.params.user).toBe('member2') + }) + + test('can change member role (adds operation)', async ({ + page, + gotoConnected, + mockConnector, + }) => { + await gotoConnected('/@testorg') + + const membersSection = page.locator('section[aria-labelledby="members-heading"]') + await expect(membersSection).toBeVisible({ timeout: 10000 }) + + // Find the role selector for member2 and change it + const roleSelect = membersSection.locator('select[name="role-member2"]') + await expect(roleSelect).toBeVisible({ timeout: 5000 }) + await roleSelect.selectOption('admin') + + // Wait a moment for the operation to be added + await page.waitForTimeout(500) + + // Should have added a change role operation + const operations = await mockConnector.getOperations() + expect(operations).toHaveLength(1) + expect(operations[0]?.type).toBe('org:add-user') // npm org set uses same command + // Note: The app may strip the @ prefix from org names + expect(operations[0]?.params.user).toBe('member2') + expect(operations[0]?.params.role).toBe('admin') + }) +}) + +test.describe('Package Access Controls', () => { + test.beforeEach(async ({ mockConnector }) => { + // Set up org with teams (required for the team access dropdown) + await mockConnector.setOrgData('@nuxt', { + users: { + testuser: 'owner', + }, + teams: ['core', 'docs', 'triage'], + }) + await mockConnector.setUserOrgs(['@nuxt']) + + // Set up package collaborators - teams use "scope:team" format + await mockConnector.setPackageData('@nuxt/kit', { + collaborators: { + 'nuxt:core': 'read-write', + 'nuxt:docs': 'read-only', + }, + }) + }) + + test('shows team access section on scoped package when connected', async ({ + page, + gotoConnected, + }) => { + // First navigate to home to verify connector is working + await gotoConnected('/') + await expect(page.getByRole('link', { name: '@testuser' })).toBeVisible({ timeout: 10000 }) + + // Now navigate to the package page + await page.goto('/package/@nuxt/kit') + + // Wait for the page to load + await expect(page.locator('h1')).toContainText('kit', { timeout: 30000 }) + + // Verify we're still connected + await expect(page.getByRole('link', { name: '@testuser' })).toBeVisible({ timeout: 5000 }) + + // Should show the team access section + const accessSection = page.locator('section[aria-labelledby="access-heading"]') + await expect(accessSection).toBeVisible({ timeout: 15000 }) + + // Should show the title + await expect(accessSection.getByRole('heading', { name: /team access/i })).toBeVisible() + }) + + test('displays collaborators with correct permissions', async ({ page, gotoConnected }) => { + // Connect on home first, then navigate to package + await gotoConnected('/') + await expect(page.getByRole('link', { name: '@testuser' })).toBeVisible({ timeout: 10000 }) + await page.goto('/package/@nuxt/kit') + await expect(page.locator('h1')).toContainText('kit', { timeout: 30000 }) + + const accessSection = page.locator('section[aria-labelledby="access-heading"]') + await expect(accessSection).toBeVisible({ timeout: 15000 }) + + // Wait for collaborators to load + const collaboratorsList = accessSection.getByRole('list', { name: /team access list/i }) + await expect(collaboratorsList).toBeVisible({ timeout: 10000 }) + + // Should show core team with read-write (rw) + await expect(collaboratorsList.getByText('core')).toBeVisible() + await expect(collaboratorsList.locator('span', { hasText: 'rw' })).toBeVisible() + + // Should show docs team with read-only (ro) + await expect(collaboratorsList.getByText('docs')).toBeVisible() + await expect(collaboratorsList.locator('span', { hasText: 'ro' })).toBeVisible() + }) + + test('can grant team access (creates operation)', async ({ + page, + gotoConnected, + mockConnector, + }) => { + // Connect on home first, then navigate to package + await gotoConnected('/') + await expect(page.getByRole('link', { name: '@testuser' })).toBeVisible({ timeout: 10000 }) + await page.goto('/package/@nuxt/kit') + await expect(page.locator('h1')).toContainText('kit', { timeout: 30000 }) + + const accessSection = page.locator('section[aria-labelledby="access-heading"]') + await expect(accessSection).toBeVisible({ timeout: 15000 }) + + // Click "Grant team access" button + await accessSection.getByRole('button', { name: /grant team access/i }).click() + + // Select a team from dropdown + const teamSelect = accessSection.locator('select[name="grant-team"]') + await expect(teamSelect).toBeVisible() + + // Wait for teams to load (options will appear) + await expect(teamSelect.locator('option')).toHaveCount(4, { timeout: 10000 }) // 1 placeholder + 3 teams + + await teamSelect.selectOption({ label: 'nuxt:triage' }) + + // Select permission level + const permissionSelect = accessSection.locator('select[name="grant-permission"]') + await permissionSelect.selectOption('read-write') + + // Click grant button + await accessSection.getByRole('button', { name: /^grant$/i }).click() + + // Wait for operation to be added + await page.waitForTimeout(500) + + // Verify operation was added + const operations = await mockConnector.getOperations() + expect(operations).toHaveLength(1) + expect(operations[0]?.type).toBe('access:grant') + // scopeTeam includes @ prefix (from buildScopeTeam utility) + expect(operations[0]?.params.scopeTeam).toBe('@nuxt:triage') + expect(operations[0]?.params.pkg).toBe('@nuxt/kit') + expect(operations[0]?.params.permission).toBe('read-write') + }) + + test('can revoke team access (creates operation)', async ({ + page, + gotoConnected, + mockConnector, + }) => { + // Connect on home first, then navigate to package + await gotoConnected('/') + await expect(page.getByRole('link', { name: '@testuser' })).toBeVisible({ timeout: 10000 }) + await page.goto('/package/@nuxt/kit') + await expect(page.locator('h1')).toContainText('kit', { timeout: 30000 }) + + const accessSection = page.locator('section[aria-labelledby="access-heading"]') + await expect(accessSection).toBeVisible({ timeout: 15000 }) + + // Wait for collaborators to load + const collaboratorsList = accessSection.getByRole('list', { name: /team access list/i }) + await expect(collaboratorsList).toBeVisible({ timeout: 10000 }) + + // Click revoke button for docs team - aria-label is "Revoke docs access" + await accessSection.getByRole('button', { name: /revoke docs access/i }).click() + + // Wait for operation to be added + await page.waitForTimeout(500) + + // Verify operation was added + const operations = await mockConnector.getOperations() + expect(operations).toHaveLength(1) + expect(operations[0]?.type).toBe('access:revoke') + expect(operations[0]?.params.scopeTeam).toBe('nuxt:docs') + expect(operations[0]?.params.pkg).toBe('@nuxt/kit') + }) + + test('does not show access section on unscoped packages', async ({ page, gotoConnected }) => { + // Navigate to an unscoped package + await gotoConnected('/package/lodash') + + // The access section should not be visible (component only shows for scoped packages) + const accessSection = page.locator('section[aria-labelledby="access-heading"]') + await expect(accessSection).not.toBeVisible() + }) + + test('can cancel grant access form', async ({ page, gotoConnected }) => { + // Connect on home first, then navigate to package + await gotoConnected('/') + await expect(page.getByRole('link', { name: '@testuser' })).toBeVisible({ timeout: 10000 }) + await page.goto('/package/@nuxt/kit') + await expect(page.locator('h1')).toContainText('kit', { timeout: 30000 }) + + const accessSection = page.locator('section[aria-labelledby="access-heading"]') + await expect(accessSection).toBeVisible({ timeout: 15000 }) + + // Open grant access form + await accessSection.getByRole('button', { name: /grant team access/i }).click() + + // Form should be visible + const teamSelect = accessSection.locator('select[name="grant-team"]') + await expect(teamSelect).toBeVisible() + + // Click cancel button + await accessSection.getByRole('button', { name: /cancel granting access/i }).click() + + // Form should be hidden, grant button should be back + await expect(teamSelect).not.toBeVisible() + await expect(accessSection.getByRole('button', { name: /grant team access/i })).toBeVisible() + }) +}) + +test.describe('Operations Queue', () => { + test('shows operations in connector modal', async ({ page, gotoConnected, mockConnector }) => { + // Add some operations + mockConnector.addOperation({ + type: 'org:add-user', + params: { org: '@testorg', user: 'newuser', role: 'developer' }, + description: 'Add @newuser to @testorg as developer', + command: 'npm org set @testorg newuser developer', + }) + mockConnector.addOperation({ + type: 'org:rm-user', + params: { org: '@testorg', user: 'olduser' }, + description: 'Remove @olduser from @testorg', + command: 'npm org rm @testorg olduser', + }) + + await gotoConnected('/') + + // Should show operation count badge + const badge = page.locator('span:has-text("2")').first() + await expect(badge).toBeVisible() + + // Open connector modal + await page.getByRole('button', { name: /connected/i }).click() + const modal = page.getByRole('dialog') + + // Should show both operations + await expect(modal).toContainText('Add @newuser') + await expect(modal).toContainText('Remove @olduser') + }) + + test('can approve and execute operations', async ({ page, gotoConnected, mockConnector }) => { + mockConnector.addOperation({ + type: 'org:add-user', + params: { org: '@testorg', user: 'newuser', role: 'developer' }, + description: 'Add @newuser to @testorg', + command: 'npm org set @testorg newuser developer', + }) + + await gotoConnected('/') + + // Open connector modal + await page.getByRole('button', { name: /connected/i }).click() + const modal = page.getByRole('dialog') + await expect(modal).toBeVisible() + + // Click "Approve all" - wait for it to be visible first + const approveAllBtn = modal.getByRole('button', { name: /approve all/i }) + await expect(approveAllBtn).toBeVisible({ timeout: 5000 }) + await approveAllBtn.click() + + // Wait for the state to update + await page.waitForTimeout(300) + + // Verify operation is approved + let operations = await mockConnector.getOperations() + expect(operations[0]?.status).toBe('approved') + + // Click "Execute" - wait for it to be visible first + const executeBtn = modal.getByRole('button', { name: /execute/i }) + await expect(executeBtn).toBeVisible({ timeout: 5000 }) + await executeBtn.click() + + // Wait for execution to complete + await page.waitForTimeout(500) + + // Verify operation is completed + operations = await mockConnector.getOperations() + expect(operations[0]?.status).toBe('completed') + }) + + test('can clear pending operations', async ({ page, gotoConnected, mockConnector }) => { + mockConnector.addOperation({ + type: 'org:add-user', + params: { org: '@testorg', user: 'newuser', role: 'developer' }, + description: 'Add @newuser to @testorg', + command: 'npm org set @testorg newuser developer', + }) + + await gotoConnected('/') + + // Open connector modal + await page.getByRole('button', { name: /connected/i }).click() + const modal = page.getByRole('dialog') + + // Click "Clear all" + await modal.getByRole('button', { name: /clear/i }).click() + + // Verify operations are cleared + const operations = await mockConnector.getOperations() + expect(operations).toHaveLength(0) + }) +}) diff --git a/tests/global-setup.ts b/tests/global-setup.ts new file mode 100644 index 00000000..6877afc2 --- /dev/null +++ b/tests/global-setup.ts @@ -0,0 +1,25 @@ +/** + * Playwright global setup - starts the mock connector server before all tests. + */ + +import { MockConnectorServer, DEFAULT_TEST_CONFIG } from './helpers/mock-connector' + +let mockServer: MockConnectorServer | null = null + +export default async function globalSetup() { + console.log('[Global Setup] Starting mock connector server...') + + mockServer = new MockConnectorServer(DEFAULT_TEST_CONFIG) + + try { + await mockServer.start() + console.log(`[Global Setup] Mock connector ready at http://127.0.0.1:${mockServer.port}`) + console.log(`[Global Setup] Test token: ${mockServer.token}`) + + // Store the server instance for global teardown + ;(globalThis as Record).__mockConnectorServer = mockServer + } catch (error) { + console.error('[Global Setup] Failed to start mock connector:', error) + throw error + } +} diff --git a/tests/global-teardown.ts b/tests/global-teardown.ts new file mode 100644 index 00000000..ad44062f --- /dev/null +++ b/tests/global-teardown.ts @@ -0,0 +1,24 @@ +/** + * Playwright global teardown - stops the mock connector server after all tests. + */ + +import type { MockConnectorServer } from './helpers/mock-connector' + +export default async function globalTeardown() { + console.log('[Global Teardown] Stopping mock connector server...') + + const mockServer = (globalThis as Record).__mockConnectorServer as + | MockConnectorServer + | undefined + + if (mockServer) { + try { + await mockServer.stop() + delete (globalThis as Record).__mockConnectorServer + } catch (error) { + console.error('[Global Teardown] Error stopping mock connector:', error) + } + } else { + console.log('[Global Teardown] No mock connector server found') + } +} diff --git a/tests/helpers/fixtures.ts b/tests/helpers/fixtures.ts new file mode 100644 index 00000000..8e2576bb --- /dev/null +++ b/tests/helpers/fixtures.ts @@ -0,0 +1,178 @@ +/** + * Playwright test fixtures for connector-related tests. + * + * These fixtures extend the base Nuxt test utilities with + * connector-specific helpers. + */ + +import { test as base } from '@nuxt/test-utils/playwright' +import { DEFAULT_TEST_CONFIG } from './mock-connector' + +/** The test token for authentication */ +const TEST_TOKEN = DEFAULT_TEST_CONFIG.token +/** The connector port */ +const TEST_PORT = DEFAULT_TEST_CONFIG.port ?? 31415 + +/** + * Helper to make requests to the mock connector server. + * This allows tests to set up state before running. + */ +export class MockConnectorClient { + private token: string + private baseUrl: string + + constructor(token: string, port: number) { + this.token = token + this.baseUrl = `http://127.0.0.1:${port}` + } + + private async request(path: string, options?: RequestInit): Promise { + const response = await fetch(`${this.baseUrl}${path}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.token}`, + ...options?.headers, + }, + }) + return response.json() as Promise + } + + /** Reset the mock connector state */ + async reset(): Promise { + // Reset state via test endpoint (no auth required) + await fetch(`${this.baseUrl}/__test__/reset`, { method: 'POST' }) + + // Connect to establish session + await fetch(`${this.baseUrl}/connect`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: this.token }), + }) + } + + /** Set org data */ + async setOrgData( + org: string, + data: { + users?: Record + teams?: string[] + teamMembers?: Record + }, + ): Promise { + await fetch(`${this.baseUrl}/__test__/org`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ org, ...data }), + }) + } + + /** Set user orgs */ + async setUserOrgs(orgs: string[]): Promise { + await fetch(`${this.baseUrl}/__test__/user-orgs`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ orgs }), + }) + } + + /** Set user packages */ + async setUserPackages(packages: Record): Promise { + await fetch(`${this.baseUrl}/__test__/user-packages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ packages }), + }) + } + + /** Set package data */ + async setPackageData( + pkg: string, + data: { collaborators?: Record }, + ): Promise { + await fetch(`${this.baseUrl}/__test__/package`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ package: pkg, ...data }), + }) + } + + /** Add an operation */ + async addOperation(operation: { + type: string + params: Record + description: string + command: string + dependsOn?: string + }): Promise<{ id: string; status: string }> { + const result = await this.request<{ success: boolean; data: { id: string; status: string } }>( + '/operations', + { + method: 'POST', + body: JSON.stringify(operation), + }, + ) + return result.data + } + + /** Get all operations */ + async getOperations(): Promise< + Array<{ id: string; type: string; status: string; params: Record }> + > { + const result = await this.request<{ + success: boolean + data: { + operations: Array<{ + id: string + type: string + status: string + params: Record + }> + } + }>('/state') + return result.data.operations + } +} + +export interface ConnectorFixtures { + /** Client to interact with the mock connector server */ + mockConnector: MockConnectorClient + /** The test token for authentication */ + testToken: string + /** The connector port */ + connectorPort: number + /** + * Navigate to a page with connector credentials in URL params. + * This triggers auto-connection on page load. + */ + gotoConnected: (path: string) => Promise +} + +/** + * Extended test with connector fixtures. + */ +export const test = base.extend({ + mockConnector: async ({ page: _ }, use) => { + const client = new MockConnectorClient(TEST_TOKEN, TEST_PORT) + // Reset state before each test + await client.reset() + await use(client) + }, + + testToken: TEST_TOKEN, + + connectorPort: TEST_PORT, + + gotoConnected: async ({ goto, testToken, connectorPort }, use) => { + const navigateConnected = async (path: string) => { + // Remove leading slash if present for clean URL construction + const cleanPath = path.startsWith('/') ? path : `/${path}` + const separator = cleanPath.includes('?') ? '&' : '?' + const urlWithParams = `${cleanPath}${separator}token=${testToken}&port=${connectorPort}` + await goto(urlWithParams, { waitUntil: 'networkidle' }) + } + await use(navigateConnected) + }, +}) + +export { expect } from '@nuxt/test-utils/playwright' diff --git a/tests/helpers/mock-connector-state.ts b/tests/helpers/mock-connector-state.ts new file mode 100644 index 00000000..c24203d8 --- /dev/null +++ b/tests/helpers/mock-connector-state.ts @@ -0,0 +1,52 @@ +/** + * Re-export from shared test utilities for backward compatibility. + * The actual implementation is in shared/test-utils/ for use by both + * Playwright E2E tests and Vitest browser tests. + */ + +export { + // Types + type OrgRole, + type AccessLevel, + type MockConnectorConfig, + type MockOrgData, + type MockPackageData, + type MockConnectorStateData as MockConnectorState, + type NewOperationInput, + type ExecuteOptions, + type ExecuteResult, + type PendingOperation, + type OperationType, + type OperationResult, + + // State management + MockConnectorStateManager, + createMockConnectorState, + DEFAULT_MOCK_CONFIG, +} from '../../shared/test-utils' + +// Singleton management for the mock server +import { + MockConnectorStateManager, + createMockConnectorState, + DEFAULT_MOCK_CONFIG, + type MockConnectorConfig, +} from '../../shared/test-utils' + +let globalStateManager: MockConnectorStateManager | null = null + +export function initGlobalMockState(config: MockConnectorConfig): MockConnectorStateManager { + globalStateManager = new MockConnectorStateManager(createMockConnectorState(config)) + return globalStateManager +} + +export function getGlobalMockState(): MockConnectorStateManager { + if (!globalStateManager) { + throw new Error('Mock connector state not initialized. Call initGlobalMockState() first.') + } + return globalStateManager +} + +export function resetGlobalMockState(): void { + globalStateManager?.reset() +} diff --git a/tests/helpers/mock-connector.ts b/tests/helpers/mock-connector.ts new file mode 100644 index 00000000..154c8598 --- /dev/null +++ b/tests/helpers/mock-connector.ts @@ -0,0 +1,597 @@ +/** + * Mock connector HTTP server for E2E testing. + * + * This server implements the same API as the real connector CLI, + * allowing Playwright tests to exercise authenticated features + * without requiring the real connector to be running. + */ + +import type { H3Event } from 'h3-next' +import { + createApp, + createRouter, + eventHandler, + readBody, + getQuery, + getRouterParam, + setResponseStatus, + toNodeListener, + handleCors, + type CorsOptions, +} from 'h3-next' +import { createServer, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import { + type MockConnectorConfig, + type MockConnectorStateManager, + type OperationType, + initGlobalMockState, +} from './mock-connector-state' + +// CORS options to allow requests from any localhost origin +const corsOptions: CorsOptions = { + origin: '*', // Allow all origins for testing + methods: ['GET', 'POST', 'DELETE', 'OPTIONS'], + allowHeaders: ['Content-Type', 'Authorization'], +} + +/** + * Creates the H3 app that implements the connector API. + */ +function createMockConnectorApp(stateManager: MockConnectorStateManager) { + const app = createApp() + const router = createRouter() + + // Handle CORS for all requests (including preflight) + app.use( + eventHandler(event => { + const corsResult = handleCors(event, corsOptions) + if (corsResult !== false) { + return corsResult + } + }), + ) + + // Auth middleware helper + const requireAuth = (event: H3Event) => { + const authHeader = event.node?.req?.headers?.authorization + if (!authHeader || !authHeader.startsWith('Bearer ')) { + setResponseStatus(event, 401) + return { success: false, error: 'Authorization required' } + } + const token = authHeader.slice(7) + if (token !== stateManager.token) { + setResponseStatus(event, 401) + return { success: false, error: 'Invalid token' } + } + if (!stateManager.isConnected()) { + setResponseStatus(event, 401) + return { success: false, error: 'Not connected' } + } + return null // Auth passed + } + + // POST /connect - Validate token and establish connection + router.post( + '/connect', + eventHandler(async event => { + const body = await readBody<{ token?: string }>(event) + const token = body?.token + + if (!token || token !== stateManager.token) { + setResponseStatus(event, 401) + return { success: false, error: 'Invalid token' } + } + + stateManager.connect(token) + + return { + success: true, + data: { + npmUser: stateManager.config.npmUser, + avatar: stateManager.config.avatar ?? null, + connectedAt: stateManager.state.connectedAt, + }, + } + }), + ) + + // GET /state - Get current session state + router.get( + '/state', + eventHandler(event => { + const authError = requireAuth(event) + if (authError) return authError + + return { + success: true, + data: { + npmUser: stateManager.config.npmUser, + avatar: stateManager.config.avatar ?? null, + operations: stateManager.getOperations(), + }, + } + }), + ) + + // POST /operations - Add a single operation + router.post( + '/operations', + eventHandler(async event => { + const authError = requireAuth(event) + if (authError) return authError + + interface OperationBody { + type?: string + params?: Record + description?: string + command?: string + dependsOn?: string + } + const body = await readBody(event) + if (!body || !body.type || !body.description || !body.command) { + setResponseStatus(event, 400) + return { success: false, error: 'Missing required fields' } + } + + const operation = stateManager.addOperation({ + type: body.type as OperationType, + params: body.params ?? {}, + description: body.description, + command: body.command, + dependsOn: body.dependsOn, + }) + + return { success: true, data: operation } + }), + ) + + // POST /operations/batch - Add multiple operations + router.post( + '/operations/batch', + eventHandler(async event => { + const authError = requireAuth(event) + if (authError) return authError + + const body = await readBody(event) + if (!Array.isArray(body)) { + setResponseStatus(event, 400) + return { success: false, error: 'Expected array of operations' } + } + + const operations = stateManager.addOperations(body) + return { success: true, data: operations } + }), + ) + + // DELETE /operations?id= - Remove a single operation + router.delete( + '/operations', + eventHandler(event => { + const authError = requireAuth(event) + if (authError) return authError + + const query = getQuery(event) + const id = query.id as string + if (!id) { + setResponseStatus(event, 400) + return { success: false, error: 'Missing operation id' } + } + + const removed = stateManager.removeOperation(id) + if (!removed) { + setResponseStatus(event, 404) + return { success: false, error: 'Operation not found or cannot be removed' } + } + + return { success: true } + }), + ) + + // DELETE /operations/all - Clear all non-running operations + router.delete( + '/operations/all', + eventHandler(event => { + const authError = requireAuth(event) + if (authError) return authError + + const removed = stateManager.clearOperations() + return { success: true, data: { removed } } + }), + ) + + // POST /approve?id= - Approve a single operation + router.post( + '/approve', + eventHandler(event => { + const authError = requireAuth(event) + if (authError) return authError + + const query = getQuery(event) + const id = query.id as string + if (!id) { + setResponseStatus(event, 400) + return { success: false, error: 'Missing operation id' } + } + + const operation = stateManager.approveOperation(id) + if (!operation) { + setResponseStatus(event, 404) + return { success: false, error: 'Operation not found or not pending' } + } + + return { success: true, data: operation } + }), + ) + + // POST /approve-all - Approve all pending operations + router.post( + '/approve-all', + eventHandler(event => { + const authError = requireAuth(event) + if (authError) return authError + + const approved = stateManager.approveAll() + return { success: true, data: { approved } } + }), + ) + + // POST /retry?id= - Retry a failed operation + router.post( + '/retry', + eventHandler(event => { + const authError = requireAuth(event) + if (authError) return authError + + const query = getQuery(event) + const id = query.id as string + if (!id) { + setResponseStatus(event, 400) + return { success: false, error: 'Missing operation id' } + } + + const operation = stateManager.retryOperation(id) + if (!operation) { + setResponseStatus(event, 404) + return { success: false, error: 'Operation not found or not failed' } + } + + return { success: true, data: operation } + }), + ) + + // POST /execute - Execute all approved operations + router.post( + '/execute', + eventHandler(async event => { + const authError = requireAuth(event) + if (authError) return authError + + const body = await readBody<{ otp?: string }>(event).catch(() => ({ otp: undefined })) + const otp = body?.otp + + const { results, otpRequired } = stateManager.executeOperations({ otp }) + + return { + success: true, + data: { results, otpRequired }, + } + }), + ) + + // GET /org/:org/users - List org members + router.get( + '/org/:org/users', + eventHandler(event => { + const authError = requireAuth(event) + if (authError) return authError + + const org = getRouterParam(event, 'org') + if (!org) { + setResponseStatus(event, 400) + return { success: false, error: 'Missing org parameter' } + } + + // Normalize org name: add @ prefix if not present (for internal lookup) + const normalizedOrg = org.startsWith('@') ? org : `@${org}` + + const users = stateManager.getOrgUsers(normalizedOrg) + if (users === null) { + // Return empty object for unknown orgs (simulates no access) + return { success: true, data: {} } + } + + return { success: true, data: users } + }), + ) + + // GET /org/:org/teams - List org teams + router.get( + '/org/:org/teams', + eventHandler(event => { + const authError = requireAuth(event) + if (authError) return authError + + const org = getRouterParam(event, 'org') + if (!org) { + setResponseStatus(event, 400) + return { success: false, error: 'Missing org parameter' } + } + + // Normalize org name: add @ prefix if not present + const normalizedOrg = org.startsWith('@') ? org : `@${org}` + // Extract org name without @ prefix for formatting team names + const orgName = normalizedOrg.slice(1) + + const teams = stateManager.getOrgTeams(normalizedOrg) + // Return teams in "org:team" format (matching real npm team ls output) + const formattedTeams = teams ? teams.map(t => `${orgName}:${t}`) : [] + return { success: true, data: formattedTeams } + }), + ) + + // GET /team/:scopeTeam/users - List team members + router.get( + '/team/:scopeTeam/users', + eventHandler(event => { + const authError = requireAuth(event) + if (authError) return authError + + const scopeTeam = getRouterParam(event, 'scopeTeam') + if (!scopeTeam) { + setResponseStatus(event, 400) + return { success: false, error: 'Missing scopeTeam parameter' } + } + + // Format: @scope:team + if (!scopeTeam.startsWith('@') || !scopeTeam.includes(':')) { + setResponseStatus(event, 400) + return { success: false, error: 'Invalid scope:team format (expected @scope:team)' } + } + + const [scope, team] = scopeTeam.split(':') + if (!scope || !team) { + setResponseStatus(event, 400) + return { success: false, error: 'Invalid scope:team format' } + } + + const users = stateManager.getTeamUsers(scope, team) + return { success: true, data: users ?? [] } + }), + ) + + // GET /package/:pkg/collaborators - List package collaborators + router.get( + '/package/:pkg/collaborators', + eventHandler(event => { + const authError = requireAuth(event) + if (authError) return authError + + const pkg = getRouterParam(event, 'pkg') + if (!pkg) { + setResponseStatus(event, 400) + return { success: false, error: 'Missing package parameter' } + } + + // Decode the package name (scoped packages like @nuxt/kit come URL-encoded) + const decodedPkg = decodeURIComponent(pkg) + const collaborators = stateManager.getPackageCollaborators(decodedPkg) + return { success: true, data: collaborators ?? {} } + }), + ) + + // GET /user/packages - List user's packages + router.get( + '/user/packages', + eventHandler(event => { + const authError = requireAuth(event) + if (authError) return authError + + const packages = stateManager.getUserPackages() + return { success: true, data: packages } + }), + ) + + // GET /user/orgs - List user's orgs + router.get( + '/user/orgs', + eventHandler(event => { + const authError = requireAuth(event) + if (authError) return authError + + const orgs = stateManager.getUserOrgs() + return { success: true, data: orgs } + }), + ) + + // ============ Test-only endpoints for setting up mock data ============ + + // POST /__test__/reset - Reset all mock state + router.post( + '/__test__/reset', + eventHandler(() => { + stateManager.reset() + return { success: true } + }), + ) + + // POST /__test__/org - Set org data + router.post( + '/__test__/org', + eventHandler(async event => { + interface OrgSetupBody { + org: string + users?: Record + teams?: string[] + teamMembers?: Record + } + const body = await readBody(event) + if (!body?.org) { + setResponseStatus(event, 400) + return { success: false, error: 'Missing org parameter' } + } + + stateManager.setOrgData(body.org, { + users: body.users, + teams: body.teams, + teamMembers: body.teamMembers, + }) + + return { success: true } + }), + ) + + // POST /__test__/user-orgs - Set user's orgs + router.post( + '/__test__/user-orgs', + eventHandler(async event => { + const body = await readBody<{ orgs: string[] }>(event) + if (!body?.orgs) { + setResponseStatus(event, 400) + return { success: false, error: 'Missing orgs parameter' } + } + + stateManager.setUserOrgs(body.orgs) + return { success: true } + }), + ) + + // POST /__test__/user-packages - Set user's packages + router.post( + '/__test__/user-packages', + eventHandler(async event => { + const body = await readBody<{ packages: Record }>(event) + if (!body?.packages) { + setResponseStatus(event, 400) + return { success: false, error: 'Missing packages parameter' } + } + + stateManager.setUserPackages(body.packages) + return { success: true } + }), + ) + + // POST /__test__/package - Set package data + router.post( + '/__test__/package', + eventHandler(async event => { + interface PackageSetupBody { + package: string + collaborators?: Record + } + const body = await readBody(event) + if (!body?.package) { + setResponseStatus(event, 400) + return { success: false, error: 'Missing package parameter' } + } + + stateManager.setPackageData(body.package, { + collaborators: body.collaborators ?? {}, + }) + + return { success: true } + }), + ) + + app.use(router.handler) + return app +} + +/** + * Mock connector server instance. + */ +export class MockConnectorServer { + private server: Server | null = null + private stateManager: MockConnectorStateManager + + constructor(config: MockConnectorConfig) { + this.stateManager = initGlobalMockState(config) + } + + /** + * Start the mock server. + */ + async start(): Promise { + if (this.server) { + throw new Error('Mock connector server is already running') + } + + const app = createMockConnectorApp(this.stateManager) + this.server = createServer(toNodeListener(app)) + + return new Promise((resolve, reject) => { + const port = this.stateManager.port + + this.server!.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EADDRINUSE') { + reject(new Error(`Port ${port} is already in use. Is the real connector running?`)) + } else { + reject(err) + } + }) + + this.server!.listen(port, '127.0.0.1', () => { + const addr = this.server!.address() as AddressInfo + console.log(`[Mock Connector] Started on http://127.0.0.1:${addr.port}`) + resolve() + }) + }) + } + + /** + * Stop the mock server. + */ + async stop(): Promise { + if (!this.server) return + + return new Promise((resolve, reject) => { + this.server!.close(err => { + if (err) { + reject(err) + } else { + console.log('[Mock Connector] Stopped') + this.server = null + resolve() + } + }) + }) + } + + /** + * Get the state manager for test setup. + */ + get state(): MockConnectorStateManager { + return this.stateManager + } + + /** + * Get the port the server is running on. + */ + get port(): number { + return this.stateManager.port + } + + /** + * Get the token for authentication. + */ + get token(): string { + return this.stateManager.token + } + + /** + * Reset state between tests. + */ + reset(): void { + this.stateManager.reset() + } +} + +// Export a function to get the global state manager (for tests that need to manipulate state) +export { + getGlobalMockState, + resetGlobalMockState, + DEFAULT_MOCK_CONFIG, +} from './mock-connector-state' + +// Alias for backward compatibility +export { DEFAULT_MOCK_CONFIG as DEFAULT_TEST_CONFIG } from './mock-connector-state' From d169a85b14e1ac0634b92471e6e819212b4b4ec2 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Wed, 28 Jan 2026 22:55:32 +0000 Subject: [PATCH 2/2] chore: some fixes --- test/nuxt/components/ConnectorModal.spec.ts | 9 ++++++--- tests/connector.spec.ts | 8 ++++---- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/test/nuxt/components/ConnectorModal.spec.ts b/test/nuxt/components/ConnectorModal.spec.ts index c9079d83..ca47208e 100644 --- a/test/nuxt/components/ConnectorModal.spec.ts +++ b/test/nuxt/components/ConnectorModal.spec.ts @@ -10,6 +10,10 @@ import { mountSuspended } from '@nuxt/test-utils/runtime' import { ref, computed, readonly, nextTick } from 'vue' import type { VueWrapper } from '@vue/test-utils' import type { MockConnectorTestControls } from '../../../shared/test-utils' + +/** Subset of MockConnectorTestControls for unit tests that don't need stateManager */ +type UnitTestConnectorControls = Omit +import type { PendingOperation } from '../../../cli/src/types' import { ConnectorModal } from '#components' // Mock state that will be controlled by tests @@ -18,7 +22,7 @@ const mockState = ref({ connecting: false, npmUser: null as string | null, avatar: null as string | null, - operations: [] as Array<{ id: string; status: string }>, + operations: [] as PendingOperation[], error: null as string | null, lastExecutionTime: null as number | null, }) @@ -81,8 +85,7 @@ function createMockUseConnector() { } // Test controls for manipulating mock state -const mockControls: MockConnectorTestControls = { - stateManager: null as unknown as MockConnectorTestControls['stateManager'], +const mockControls: UnitTestConnectorControls = { setOrgData: vi.fn(), setUserOrgs: vi.fn(), setUserPackages: vi.fn(), diff --git a/tests/connector.spec.ts b/tests/connector.spec.ts index b1f28b84..a8af265c 100644 --- a/tests/connector.spec.ts +++ b/tests/connector.spec.ts @@ -404,13 +404,13 @@ test.describe('Package Access Controls', () => { test.describe('Operations Queue', () => { test('shows operations in connector modal', async ({ page, gotoConnected, mockConnector }) => { // Add some operations - mockConnector.addOperation({ + await mockConnector.addOperation({ type: 'org:add-user', params: { org: '@testorg', user: 'newuser', role: 'developer' }, description: 'Add @newuser to @testorg as developer', command: 'npm org set @testorg newuser developer', }) - mockConnector.addOperation({ + await mockConnector.addOperation({ type: 'org:rm-user', params: { org: '@testorg', user: 'olduser' }, description: 'Remove @olduser from @testorg', @@ -433,7 +433,7 @@ test.describe('Operations Queue', () => { }) test('can approve and execute operations', async ({ page, gotoConnected, mockConnector }) => { - mockConnector.addOperation({ + await mockConnector.addOperation({ type: 'org:add-user', params: { org: '@testorg', user: 'newuser', role: 'developer' }, description: 'Add @newuser to @testorg', @@ -473,7 +473,7 @@ test.describe('Operations Queue', () => { }) test('can clear pending operations', async ({ page, gotoConnected, mockConnector }) => { - mockConnector.addOperation({ + await mockConnector.addOperation({ type: 'org:add-user', params: { org: '@testorg', user: 'newuser', role: 'developer' }, description: 'Add @newuser to @testorg',