From b24d8b3d7d6b8c6f0db69055b92e55125f4977c7 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Wed, 28 Jan 2026 11:14:39 +0000 Subject: [PATCH 01/11] 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 1991476a5..ac89ead55 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 95ff74f42..56892b377 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 39527d2b4..80fc462f2 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 56716b3f7..0dcabc6d7 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 000000000..5e8ee32de --- /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 000000000..b434c5355 --- /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 000000000..7ce2cc371 --- /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 000000000..ddf14540a --- /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 000000000..c9079d836 --- /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 000000000..b1f28b84f --- /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 000000000..6877afc2a --- /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 000000000..ad44062f8 --- /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 000000000..8e2576bb0 --- /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 000000000..c24203d8d --- /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 000000000..154c8598a --- /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 02/11] 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 c9079d836..ca47208e2 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 b1f28b84f..a8af265c6 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', From be6aa9976d23922258ba7595a474594b9d6b5438 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 9 Feb 2026 20:31:38 +0000 Subject: [PATCH 03/11] chore: merge issues --- test/e2e/helpers/mock-connector-state.ts | 4 +-- ...l.spec.ts => HeaderConnectorModal.spec.ts} | 34 +++++++++---------- {shared => test}/test-utils/index.ts | 0 .../test-utils/mock-connector-composable.ts | 0 .../test-utils/mock-connector-state.ts | 0 .../test-utils/mock-connector-types.ts | 0 6 files changed, 19 insertions(+), 19 deletions(-) rename test/nuxt/components/{ConnectorModal.spec.ts => HeaderConnectorModal.spec.ts} (91%) rename {shared => test}/test-utils/index.ts (100%) rename {shared => test}/test-utils/mock-connector-composable.ts (100%) rename {shared => test}/test-utils/mock-connector-state.ts (100%) rename {shared => test}/test-utils/mock-connector-types.ts (100%) diff --git a/test/e2e/helpers/mock-connector-state.ts b/test/e2e/helpers/mock-connector-state.ts index c24203d8d..8dfee67a8 100644 --- a/test/e2e/helpers/mock-connector-state.ts +++ b/test/e2e/helpers/mock-connector-state.ts @@ -23,7 +23,7 @@ export { MockConnectorStateManager, createMockConnectorState, DEFAULT_MOCK_CONFIG, -} from '../../shared/test-utils' +} from '../../test-utils' // Singleton management for the mock server import { @@ -31,7 +31,7 @@ import { createMockConnectorState, DEFAULT_MOCK_CONFIG, type MockConnectorConfig, -} from '../../shared/test-utils' +} from '../../test-utils' let globalStateManager: MockConnectorStateManager | null = null diff --git a/test/nuxt/components/ConnectorModal.spec.ts b/test/nuxt/components/HeaderConnectorModal.spec.ts similarity index 91% rename from test/nuxt/components/ConnectorModal.spec.ts rename to test/nuxt/components/HeaderConnectorModal.spec.ts index ca47208e2..d3a56e4e7 100644 --- a/test/nuxt/components/ConnectorModal.spec.ts +++ b/test/nuxt/components/HeaderConnectorModal.spec.ts @@ -1,5 +1,5 @@ /** - * Tests for ConnectorModal component. + * Tests for HeaderConnectorModal component. * * Uses the mock connector composable to test various states * without needing an actual HTTP server. @@ -9,12 +9,12 @@ 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 type { MockConnectorTestControls } from '../../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' +import { HeaderConnectorModal } from '#components' // Mock state that will be controlled by tests const mockState = ref({ @@ -167,10 +167,10 @@ afterEach(() => { } }) -describe('ConnectorModal', () => { +describe('HeaderConnectorModal', () => { describe('Disconnected state', () => { it('shows connection form when not connected', async () => { - currentWrapper = await mountSuspended(ConnectorModal, { + currentWrapper = await mountSuspended(HeaderConnectorModal, { props: { open: true }, attachTo: document.body, }) @@ -193,7 +193,7 @@ describe('ConnectorModal', () => { }) it('shows the CLI command to run', async () => { - currentWrapper = await mountSuspended(ConnectorModal, { + currentWrapper = await mountSuspended(HeaderConnectorModal, { props: { open: true }, attachTo: document.body, }) @@ -204,7 +204,7 @@ describe('ConnectorModal', () => { }) it('can copy command to clipboard', async () => { - currentWrapper = await mountSuspended(ConnectorModal, { + currentWrapper = await mountSuspended(HeaderConnectorModal, { props: { open: true }, attachTo: document.body, }) @@ -223,7 +223,7 @@ describe('ConnectorModal', () => { }) it('disables connect button when token is empty', async () => { - currentWrapper = await mountSuspended(ConnectorModal, { + currentWrapper = await mountSuspended(HeaderConnectorModal, { props: { open: true }, attachTo: document.body, }) @@ -235,7 +235,7 @@ describe('ConnectorModal', () => { }) it('enables connect button when token is entered', async () => { - currentWrapper = await mountSuspended(ConnectorModal, { + currentWrapper = await mountSuspended(HeaderConnectorModal, { props: { open: true }, attachTo: document.body, }) @@ -258,7 +258,7 @@ describe('ConnectorModal', () => { // Simulate an error before mounting mockControls.simulateError('Could not reach connector. Is it running?') - currentWrapper = await mountSuspended(ConnectorModal, { + currentWrapper = await mountSuspended(HeaderConnectorModal, { props: { open: true }, attachTo: document.body, }) @@ -281,7 +281,7 @@ describe('ConnectorModal', () => { }) it('shows connected status', async () => { - currentWrapper = await mountSuspended(ConnectorModal, { + currentWrapper = await mountSuspended(HeaderConnectorModal, { props: { open: true }, attachTo: document.body, }) @@ -292,7 +292,7 @@ describe('ConnectorModal', () => { }) it('shows logged in username', async () => { - currentWrapper = await mountSuspended(ConnectorModal, { + currentWrapper = await mountSuspended(HeaderConnectorModal, { props: { open: true }, attachTo: document.body, }) @@ -303,7 +303,7 @@ describe('ConnectorModal', () => { }) it('shows disconnect button', async () => { - currentWrapper = await mountSuspended(ConnectorModal, { + currentWrapper = await mountSuspended(HeaderConnectorModal, { props: { open: true }, attachTo: document.body, }) @@ -318,7 +318,7 @@ describe('ConnectorModal', () => { }) it('hides connection form when connected', async () => { - currentWrapper = await mountSuspended(ConnectorModal, { + currentWrapper = await mountSuspended(HeaderConnectorModal, { props: { open: true }, attachTo: document.body, }) @@ -333,7 +333,7 @@ describe('ConnectorModal', () => { describe('Modal behavior', () => { it('closes modal when close button is clicked', async () => { - currentWrapper = await mountSuspended(ConnectorModal, { + currentWrapper = await mountSuspended(HeaderConnectorModal, { props: { open: true }, attachTo: document.body, }) @@ -354,7 +354,7 @@ describe('ConnectorModal', () => { }) it('closes modal when backdrop is clicked', async () => { - currentWrapper = await mountSuspended(ConnectorModal, { + currentWrapper = await mountSuspended(HeaderConnectorModal, { props: { open: true }, attachTo: document.body, }) @@ -376,7 +376,7 @@ describe('ConnectorModal', () => { }) it('does not render dialog when open is false', async () => { - currentWrapper = await mountSuspended(ConnectorModal, { + currentWrapper = await mountSuspended(HeaderConnectorModal, { props: { open: false }, attachTo: document.body, }) diff --git a/shared/test-utils/index.ts b/test/test-utils/index.ts similarity index 100% rename from shared/test-utils/index.ts rename to test/test-utils/index.ts diff --git a/shared/test-utils/mock-connector-composable.ts b/test/test-utils/mock-connector-composable.ts similarity index 100% rename from shared/test-utils/mock-connector-composable.ts rename to test/test-utils/mock-connector-composable.ts diff --git a/shared/test-utils/mock-connector-state.ts b/test/test-utils/mock-connector-state.ts similarity index 100% rename from shared/test-utils/mock-connector-state.ts rename to test/test-utils/mock-connector-state.ts diff --git a/shared/test-utils/mock-connector-types.ts b/test/test-utils/mock-connector-types.ts similarity index 100% rename from shared/test-utils/mock-connector-types.ts rename to test/test-utils/mock-connector-types.ts From 074d2dadf233823ecaa2ce25a4c69a36345bc83a Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 9 Feb 2026 21:33:24 +0000 Subject: [PATCH 04/11] test: update tests for upstream changes --- test/e2e/connector.spec.ts | 370 ++++++++----------- test/e2e/helpers/fixtures.ts | 52 ++- test/fixtures/npm-registry/orgs/testorg.json | 4 + 3 files changed, 204 insertions(+), 222 deletions(-) create mode 100644 test/fixtures/npm-registry/orgs/testorg.json diff --git a/test/e2e/connector.spec.ts b/test/e2e/connector.spec.ts index a8af265c6..4f7f760bc 100644 --- a/test/e2e/connector.spec.ts +++ b/test/e2e/connector.spec.ts @@ -5,75 +5,86 @@ * to test features that require being logged in via the connector. */ +import type { Page } from '@playwright/test' import { test, expect } from './helpers/fixtures' +/** + * When connected, the header shows "packages" and "orgs" links scoped to the user. + * This helper waits for the packages link to appear as proof of successful connection. + */ +async function expectConnected(page: Page, username = 'testuser') { + await expect(page.locator(`a[href="/~${username}"]`, { hasText: 'packages' })).toBeVisible({ + timeout: 10_000, + }) +} + +/** + * Open the connector modal by clicking the account menu button, then clicking + * the npm CLI menu item inside the dropdown. + */ +async function openConnectorModal(page: Page) { + // The AccountMenu button has aria-haspopup="true" + await page.locator('button[aria-haspopup="true"]').click() + + // In the dropdown menu, click the npm CLI item (menuitem containing ~testuser) + await page + .getByRole('menuitem') + .filter({ hasText: /~testuser/ }) + .click() + + // Wait for the dialog to appear + await expect(page.getByRole('dialog')).toBeVisible() +} + 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) + // Header should show "packages" link for the connected user + await expectConnected(page) }) - test('opens connector modal when clicking status button', async ({ page, gotoConnected }) => { + test('opens connector modal and shows connected user', async ({ page, gotoConnected }) => { await gotoConnected('/') + await expectConnected(page) - // Click the connector status button - await page.getByRole('button', { name: /connected/i }).click() + await openConnectorModal(page) - // 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('/') + await expectConnected(page) - // Open connector modal - await page.getByRole('button', { name: /connected/i }).click() + await openConnectorModal(page) - // 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 + // Close the modal 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({ + // The "packages" link should disappear since we're disconnected + await expect(page.locator('a[href="/~testuser"]', { hasText: 'packages' })).not.toBeVisible({ timeout: 5000, }) + + // The account menu button should now show "connect" text (the main button, not dropdown items) + await expect(page.getByRole('button', { name: 'connect', exact: true })).toBeVisible() }) }) test.describe('Organization Management', () => { test.beforeEach(async ({ mockConnector }) => { - // Set up mock org data await mockConnector.setOrgData('@testorg', { users: { testuser: 'owner', @@ -90,84 +101,85 @@ test.describe('Organization Management', () => { }) 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 }) + // The org management region contains the members panel + const orgManagement = page.getByRole('region', { name: /organization management/i }) + await expect(orgManagement).toBeVisible({ timeout: 10_000 }) - // 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 the members list + const membersList = page.getByRole('list', { name: /organization members/i }) + await expect(membersList).toBeVisible({ timeout: 10_000 }) - // 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() + // Members are shown as ~username links + await expect(membersList.getByRole('link', { name: '~testuser' })).toBeVisible() + await expect(membersList.getByRole('link', { name: '~member1' })).toBeVisible() + await expect(membersList.getByRole('link', { name: '~member2' })).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() + const orgManagement = page.getByRole('region', { name: /organization management/i }) + await expect(orgManagement).toBeVisible({ timeout: 10_000 }) + + const membersList = page.getByRole('list', { name: /organization members/i }) + await expect(membersList).toBeVisible({ timeout: 10_000 }) - // Click the "admin" filter button - await membersSection.getByRole('button', { name: /admin/i }).click() + // Click the "admin" filter button (inside "Filter by role" group) + await orgManagement + .getByRole('group', { name: /filter by role/i }) + .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() + await expect(membersList.getByRole('link', { name: '~member1' })).toBeVisible() + await expect(membersList.getByRole('link', { name: '~testuser' })).not.toBeVisible() + await expect(membersList.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() + const orgManagement = page.getByRole('region', { name: /organization management/i }) + await expect(orgManagement).toBeVisible({ timeout: 10_000 }) - // Type in the search input - const searchInput = membersSection.getByRole('searchbox') + const membersList = page.getByRole('list', { name: /organization members/i }) + await expect(membersList).toBeVisible({ timeout: 10_000 }) + + const searchInput = orgManagement.getByRole('searchbox', { name: /filter members/i }) 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() + await expect(membersList.getByRole('link', { name: '~member1' })).toBeVisible() + await expect(membersList.getByRole('link', { name: '~testuser' })).not.toBeVisible() + await expect(membersList.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 }) + const orgManagement = page.getByRole('region', { name: /organization management/i }) + await expect(orgManagement).toBeVisible({ timeout: 10_000 }) - // Click "Add member" button (text is "+ Add member") - await membersSection.getByRole('button', { name: /add member/i }).click() + // Click "Add member" button + await orgManagement.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"]') + // Fill in the form + const usernameInput = orgManagement.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() + // Select role + await orgManagement.locator('select[name="new-member-role"]').selectOption('admin') - // Wait a moment for the operation to be added + // Submit + await orgManagement.getByRole('button', { name: /^add$/i }).click() await page.waitForTimeout(500) - // Should have added an operation + // Verify 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') }) @@ -175,20 +187,15 @@ test.describe('Organization Management', () => { 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 }) + const orgManagement = page.getByRole('region', { name: /organization management/i }) + await expect(orgManagement).toBeVisible({ timeout: 10_000 }) - // 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 orgManagement.getByRole('button', { name: /remove member2/i }).click() 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') }) @@ -199,22 +206,17 @@ test.describe('Organization Management', () => { }) => { await gotoConnected('/@testorg') - const membersSection = page.locator('section[aria-labelledby="members-heading"]') - await expect(membersSection).toBeVisible({ timeout: 10000 }) + const orgManagement = page.getByRole('region', { name: /organization management/i }) + await expect(orgManagement).toBeVisible({ timeout: 10_000 }) - // Find the role selector for member2 and change it - const roleSelect = membersSection.locator('select[name="role-member2"]') + const roleSelect = orgManagement.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]?.type).toBe('org:add-user') expect(operations[0]?.params.user).toBe('member2') expect(operations[0]?.params.role).toBe('admin') }) @@ -222,16 +224,11 @@ test.describe('Organization Management', () => { 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', - }, + 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', @@ -240,52 +237,44 @@ test.describe('Package Access Controls', () => { }) }) - test('shows team access section on scoped package when connected', async ({ - page, - gotoConnected, - }) => { - // First navigate to home to verify connector is working + /** + * Helper: connect on home page then navigate to the package page. + * Verifies connection is established before navigating. + */ + async function goToPackageConnected(page: Page, gotoConnected: (path: string) => Promise) { await gotoConnected('/') - await expect(page.getByRole('link', { name: '@testuser' })).toBeVisible({ timeout: 10000 }) - - // Now navigate to the package page + await expectConnected(page) await page.goto('/package/@nuxt/kit') + await expect(page.locator('h1')).toContainText('kit', { timeout: 30_000 }) + } - // 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 }) + /** The access section is identified by the "Team Access" heading */ + function accessSection(page: Page) { + return page.locator('section:has(#access-heading)') + } - // Should show the team access section - const accessSection = page.locator('section[aria-labelledby="access-heading"]') - await expect(accessSection).toBeVisible({ timeout: 15000 }) + test('shows team access section on scoped package when connected', async ({ + page, + gotoConnected, + }) => { + await goToPackageConnected(page, gotoConnected) - // Should show the title - await expect(accessSection.getByRole('heading', { name: /team access/i })).toBeVisible() + await expect(accessSection(page)).toBeVisible({ timeout: 15_000 }) + await expect(page.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 }) + await goToPackageConnected(page, gotoConnected) - const accessSection = page.locator('section[aria-labelledby="access-heading"]') - await expect(accessSection).toBeVisible({ timeout: 15000 }) + await expect(accessSection(page)).toBeVisible({ timeout: 15_000 }) - // Wait for collaborators to load - const collaboratorsList = accessSection.getByRole('list', { name: /team access list/i }) - await expect(collaboratorsList).toBeVisible({ timeout: 10000 }) + const teamList = page.getByRole('list', { name: /team access list/i }) + await expect(teamList).toBeVisible({ timeout: 10_000 }) - // 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() + await expect(teamList.getByText('core')).toBeVisible() + await expect(teamList.locator('span', { hasText: 'rw' })).toBeVisible() + await expect(teamList.getByText('docs')).toBeVisible() + await expect(teamList.locator('span', { hasText: 'ro' })).toBeVisible() }) test('can grant team access (creates operation)', async ({ @@ -293,42 +282,27 @@ test.describe('Package Access Controls', () => { 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 }) + await goToPackageConnected(page, gotoConnected) - const accessSection = page.locator('section[aria-labelledby="access-heading"]') - await expect(accessSection).toBeVisible({ timeout: 15000 }) + const section = accessSection(page) + await expect(section).toBeVisible({ timeout: 15_000 }) - // Click "Grant team access" button - await accessSection.getByRole('button', { name: /grant team access/i }).click() + await section.getByRole('button', { name: /grant team access/i }).click() - // Select a team from dropdown - const teamSelect = accessSection.locator('select[name="grant-team"]') + const teamSelect = section.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 expect(teamSelect.locator('option')).toHaveCount(4, { timeout: 10_000 }) await teamSelect.selectOption({ label: 'nuxt:triage' }) - // Select permission level - const permissionSelect = accessSection.locator('select[name="grant-permission"]') + const permissionSelect = section.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 section.getByRole('button', { name: /^grant$/i }).click() 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') @@ -339,26 +313,17 @@ test.describe('Package Access Controls', () => { 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 }) + await goToPackageConnected(page, gotoConnected) - // Wait for collaborators to load - const collaboratorsList = accessSection.getByRole('list', { name: /team access list/i }) - await expect(collaboratorsList).toBeVisible({ timeout: 10000 }) + const section = accessSection(page) + await expect(section).toBeVisible({ timeout: 15_000 }) - // Click revoke button for docs team - aria-label is "Revoke docs access" - await accessSection.getByRole('button', { name: /revoke docs access/i }).click() + const teamList = page.getByRole('list', { name: /team access list/i }) + await expect(teamList).toBeVisible({ timeout: 10_000 }) - // Wait for operation to be added + await section.getByRole('button', { name: /revoke docs access/i }).click() await page.waitForTimeout(500) - // Verify operation was added const operations = await mockConnector.getOperations() expect(operations).toHaveLength(1) expect(operations[0]?.type).toBe('access:revoke') @@ -366,44 +331,24 @@ test.describe('Package Access Controls', () => { 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 }) + await goToPackageConnected(page, gotoConnected) - // Open grant access form - await accessSection.getByRole('button', { name: /grant team access/i }).click() + const section = accessSection(page) + await expect(section).toBeVisible({ timeout: 15_000 }) - // Form should be visible - const teamSelect = accessSection.locator('select[name="grant-team"]') + await section.getByRole('button', { name: /grant team access/i }).click() + const teamSelect = section.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 section.getByRole('button', { name: /cancel granting access/i }).click() await expect(teamSelect).not.toBeVisible() - await expect(accessSection.getByRole('button', { name: /grant team access/i })).toBeVisible() + await expect(section.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 await mockConnector.addOperation({ type: 'org:add-user', params: { org: '@testorg', user: 'newuser', role: 'developer' }, @@ -418,16 +363,11 @@ test.describe('Operations Queue', () => { }) await gotoConnected('/') + await expectConnected(page) - // Should show operation count badge - const badge = page.locator('span:has-text("2")').first() - await expect(badge).toBeVisible() + await openConnectorModal(page) - // 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') }) @@ -441,33 +381,27 @@ test.describe('Operations Queue', () => { }) await gotoConnected('/') + await expectConnected(page) + + await openConnectorModal(page) - // 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 + // Approve all 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 + // Execute 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') }) @@ -481,15 +415,13 @@ test.describe('Operations Queue', () => { }) await gotoConnected('/') + await expectConnected(page) - // Open connector modal - await page.getByRole('button', { name: /connected/i }).click() - const modal = page.getByRole('dialog') + await openConnectorModal(page) - // Click "Clear all" + const modal = page.getByRole('dialog') await modal.getByRole('button', { name: /clear/i }).click() - // Verify operations are cleared const operations = await mockConnector.getOperations() expect(operations).toHaveLength(0) }) diff --git a/test/e2e/helpers/fixtures.ts b/test/e2e/helpers/fixtures.ts index 8e2576bb0..a169f8df8 100644 --- a/test/e2e/helpers/fixtures.ts +++ b/test/e2e/helpers/fixtures.ts @@ -1,13 +1,59 @@ /** * Playwright test fixtures for connector-related tests. * - * These fixtures extend the base Nuxt test utilities with - * connector-specific helpers. + * These fixtures extend the shared test base (which includes external API + * mocking) with connector-specific helpers for authenticated features. */ -import { test as base } from '@nuxt/test-utils/playwright' +import type { Page, Route } from '@playwright/test' +import { test as nuxtBase } from '@nuxt/test-utils/playwright' +import { createRequire } from 'node:module' import { DEFAULT_TEST_CONFIG } from './mock-connector' +// --------------------------------------------------------------------------- +// External API mocking (same as test-utils.ts — needed so connector tests +// don't hit real npm registry, fast-npm-meta, etc.) +// --------------------------------------------------------------------------- + +const require = createRequire(import.meta.url) +const mockRoutes = require('../../fixtures/mock-routes.cjs') + +function failUnmockedRequest(route: Route, apiName: string): never { + throw new Error( + `UNMOCKED EXTERNAL API REQUEST DETECTED\n` + + `API: ${apiName}\nURL: ${route.request().url()}\n` + + `Add a fixture or update test/fixtures/mock-routes.cjs`, + ) +} + +async function setupRouteMocking(page: Page): Promise { + for (const routeDef of mockRoutes.routes) { + await page.route(routeDef.pattern, async (route: Route) => { + const url = route.request().url() + const result = mockRoutes.matchRoute(url) + if (result) { + await route.fulfill({ + status: result.response.status, + contentType: result.response.contentType, + body: result.response.body, + }) + } else { + failUnmockedRequest(route, routeDef.name) + } + }) + } +} + +const base = nuxtBase.extend<{ mockExternalApis: void }>({ + mockExternalApis: [ + async ({ page }, use) => { + await setupRouteMocking(page) + await use() + }, + { auto: true }, + ], +}) + /** The test token for authentication */ const TEST_TOKEN = DEFAULT_TEST_CONFIG.token /** The connector port */ diff --git a/test/fixtures/npm-registry/orgs/testorg.json b/test/fixtures/npm-registry/orgs/testorg.json new file mode 100644 index 000000000..7d8851809 --- /dev/null +++ b/test/fixtures/npm-registry/orgs/testorg.json @@ -0,0 +1,4 @@ +{ + "@testorg/package-a": "write", + "@testorg/package-b": "write" +} From 11d3781619d24b0d61ea251651cb066da91765f9 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Tue, 10 Feb 2026 13:16:31 +0000 Subject: [PATCH 05/11] test: fix --- knip.ts | 10 +- .../components/HeaderConnectorModal.spec.ts | 248 ++++++------------ 2 files changed, 89 insertions(+), 169 deletions(-) diff --git a/knip.ts b/knip.ts index ff10e62c9..cb02597f4 100644 --- a/knip.ts +++ b/knip.ts @@ -26,7 +26,12 @@ const config: KnipConfig = { 'uno-preset-rtl.ts!', 'scripts/**/*.ts', ], - project: ['**/*.{ts,vue,cjs,mjs}', '!test/fixtures/**'], + project: [ + '**/*.{ts,vue,cjs,mjs}', + '!test/fixtures/**', + '!test/test-utils/**', + '!test/e2e/helpers/**', + ], ignoreDependencies: [ '@iconify-json/*', '@voidzero-dev/vite-plus-core', @@ -43,6 +48,9 @@ const config: KnipConfig = { /** Oxlint plugins don't get picked up yet */ '@e18e/eslint-plugin', 'eslint-plugin-regexp', + + /** Used in test/e2e/helpers/ which is excluded from knip project scope */ + 'h3-next', ], ignoreUnresolved: ['#components', '#oauth/config'], }, diff --git a/test/nuxt/components/HeaderConnectorModal.spec.ts b/test/nuxt/components/HeaderConnectorModal.spec.ts index d3a56e4e7..9d6eeb7a8 100644 --- a/test/nuxt/components/HeaderConnectorModal.spec.ts +++ b/test/nuxt/components/HeaderConnectorModal.spec.ts @@ -9,10 +9,6 @@ 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 '../../test-utils' - -/** Subset of MockConnectorTestControls for unit tests that don't need stateManager */ -type UnitTestConnectorControls = Omit import type { PendingOperation } from '../../../cli/src/types' import { HeaderConnectorModal } from '#components' @@ -84,39 +80,22 @@ function createMockUseConnector() { } } -// Test controls for manipulating mock state -const mockControls: UnitTestConnectorControls = { - 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 - }, +function resetMockState() { + mockState.value = { + connected: false, + connecting: false, + npmUser: null, + avatar: null, + operations: [], + error: null, + lastExecutionTime: null, + } +} + +function simulateConnect() { + mockState.value.connected = true + mockState.value.npmUser = 'testuser' + mockState.value.avatar = 'https://example.com/avatar.png' } // Mock the composables at module level (vi.mock is hoisted) @@ -146,21 +125,41 @@ vi.stubGlobal('navigator', { let currentWrapper: VueWrapper | null = null /** - * Get the modal dialog element from the document body (where Teleport sends it) + * Get the modal dialog element from the document body (where Teleport sends it). + */ +function getModalDialog(): HTMLDialogElement | null { + return document.body.querySelector('dialog#connector-modal') +} + +/** + * Mount the component and open the dialog via showModal(). */ -function getModalDialog(): HTMLElement | null { - return document.body.querySelector('[role="dialog"]') +async function mountAndOpen(state?: 'connected' | 'error') { + if (state === 'connected') simulateConnect() + if (state === 'error') { + mockState.value.error = 'Could not reach connector. Is it running?' + } + + currentWrapper = await mountSuspended(HeaderConnectorModal, { + attachTo: document.body, + }) + await nextTick() + + const dialog = getModalDialog() + dialog?.showModal() + await nextTick() + + return dialog } // Reset state before each test beforeEach(() => { - mockControls.reset() + resetMockState() mockWriteText.mockClear() }) afterEach(() => { vi.clearAllMocks() - // Clean up Vue wrapper to remove teleported content if (currentWrapper) { currentWrapper.unmount() currentWrapper = null @@ -170,13 +169,7 @@ afterEach(() => { describe('HeaderConnectorModal', () => { describe('Disconnected state', () => { it('shows connection form when not connected', async () => { - currentWrapper = await mountSuspended(HeaderConnectorModal, { - props: { open: true }, - attachTo: document.body, - }) - await nextTick() - - const dialog = getModalDialog() + const dialog = await mountAndOpen() expect(dialog).not.toBeNull() // Should show the form (disconnected state) @@ -193,55 +186,29 @@ describe('HeaderConnectorModal', () => { }) it('shows the CLI command to run', async () => { - currentWrapper = await mountSuspended(HeaderConnectorModal, { - props: { open: true }, - attachTo: document.body, - }) - await nextTick() - - const dialog = getModalDialog() - expect(dialog?.textContent).toContain('npx npmx-connector') + const dialog = await mountAndOpen() + // The command is now "pnpm npmx-connector" + expect(dialog?.textContent).toContain('npmx-connector') }) - it('can copy command to clipboard', async () => { - currentWrapper = await mountSuspended(HeaderConnectorModal, { - 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('has a copy button for the command', async () => { + const dialog = await mountAndOpen() + // The copy button is inside the command block (dir="ltr" div) + const commandBlock = dialog?.querySelector('div[dir="ltr"]') + const copyBtn = commandBlock?.querySelector('button') as HTMLButtonElement + expect(copyBtn).toBeTruthy() + // The button should have a copy-related aria-label + expect(copyBtn?.getAttribute('aria-label')).toBeTruthy() }) it('disables connect button when token is empty', async () => { - currentWrapper = await mountSuspended(HeaderConnectorModal, { - props: { open: true }, - attachTo: document.body, - }) - await nextTick() - - const dialog = getModalDialog() + const dialog = await mountAndOpen() 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(HeaderConnectorModal, { - props: { open: true }, - attachTo: document.body, - }) - await nextTick() - - const dialog = getModalDialog() + const dialog = await mountAndOpen() const tokenInput = dialog?.querySelector('input[name="connector-token"]') as HTMLInputElement expect(tokenInput).not.toBeNull() @@ -255,18 +222,18 @@ describe('HeaderConnectorModal', () => { }) it('shows error message when connection fails', async () => { - // Simulate an error before mounting - mockControls.simulateError('Could not reach connector. Is it running?') + const dialog = await mountAndOpen('error') + // Error needs hasAttemptedConnect=true to show. Simulate a connect attempt first. + const tokenInput = dialog?.querySelector('input[name="connector-token"]') as HTMLInputElement + tokenInput.value = 'bad-token' + tokenInput.dispatchEvent(new Event('input', { bubbles: true })) + await nextTick() - currentWrapper = await mountSuspended(HeaderConnectorModal, { - props: { open: true }, - attachTo: document.body, - }) + const form = dialog?.querySelector('form') + form?.dispatchEvent(new Event('submit', { bubbles: true })) 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'), ) @@ -275,41 +242,18 @@ describe('HeaderConnectorModal', () => { }) describe('Connected state', () => { - beforeEach(() => { - // Start in connected state - mockControls.simulateConnect() - }) - it('shows connected status', async () => { - currentWrapper = await mountSuspended(HeaderConnectorModal, { - props: { open: true }, - attachTo: document.body, - }) - await nextTick() - - const dialog = getModalDialog() + const dialog = await mountAndOpen('connected') expect(dialog?.textContent).toContain('Connected') }) it('shows logged in username', async () => { - currentWrapper = await mountSuspended(HeaderConnectorModal, { - props: { open: true }, - attachTo: document.body, - }) - await nextTick() - - const dialog = getModalDialog() + const dialog = await mountAndOpen('connected') expect(dialog?.textContent).toContain('testuser') }) it('shows disconnect button', async () => { - currentWrapper = await mountSuspended(HeaderConnectorModal, { - props: { open: true }, - attachTo: document.body, - }) - await nextTick() - - const dialog = getModalDialog() + const dialog = await mountAndOpen('connected') const buttons = dialog?.querySelectorAll('button') const disconnectBtn = Array.from(buttons || []).find(b => b.textContent?.toLowerCase().includes('disconnect'), @@ -318,14 +262,7 @@ describe('HeaderConnectorModal', () => { }) it('hides connection form when connected', async () => { - currentWrapper = await mountSuspended(HeaderConnectorModal, { - props: { open: true }, - attachTo: document.body, - }) - await nextTick() - - const dialog = getModalDialog() - // Form and token input should not exist when connected + const dialog = await mountAndOpen('connected') const form = dialog?.querySelector('form') expect(form).toBeNull() }) @@ -333,57 +270,32 @@ describe('HeaderConnectorModal', () => { describe('Modal behavior', () => { it('closes modal when close button is clicked', async () => { - currentWrapper = await mountSuspended(HeaderConnectorModal, { - props: { open: true }, - attachTo: document.body, - }) - await nextTick() + const dialog = await mountAndOpen() - 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(HeaderConnectorModal, { - 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"]', + // Find the close button (ButtonBase with close icon) in the dialog header + const closeBtn = Array.from(dialog?.querySelectorAll('button') ?? []).find( + b => + b.querySelector('[class*="close"]') || + b.getAttribute('aria-label')?.toLowerCase().includes('close'), ) as HTMLButtonElement - expect(backdrop).not.toBeNull() + expect(closeBtn).toBeTruthy() - backdrop?.click() + 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]) + // Dialog should be closed (open attribute removed) + expect(dialog?.open).toBe(false) }) - it('does not render dialog when open is false', async () => { + it('does not render dialog content when not opened', async () => { currentWrapper = await mountSuspended(HeaderConnectorModal, { - props: { open: false }, attachTo: document.body, }) await nextTick() const dialog = getModalDialog() - expect(dialog).toBeNull() + // Dialog exists in DOM but should not be open + expect(dialog?.open).toBeFalsy() }) }) }) From d825361c8517f7c4e65e8ce51e0c3494ae5fcc30 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Tue, 10 Feb 2026 14:47:11 +0000 Subject: [PATCH 06/11] test: resolve issues --- playwright.config.ts | 3 +- test/e2e/connector.spec.ts | 5 +++ test/e2e/global-setup.ts | 20 +++++++---- test/e2e/global-teardown.ts | 24 -------------- test/e2e/helpers/fixtures.ts | 53 ++++++++++++++---------------- test/e2e/helpers/mock-connector.ts | 1 + 6 files changed, 46 insertions(+), 60 deletions(-) delete mode 100644 test/e2e/global-teardown.ts diff --git a/playwright.config.ts b/playwright.config.ts index 38ac20bce..45c8d3ebf 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -18,9 +18,8 @@ export default defineConfig({ reuseExistingServer: false, timeout: 60_000, }, - // Start/stop mock connector server before/after all tests + // Start/stop mock connector server before/after all tests (teardown via returned closure) globalSetup: fileURLToPath(new URL('./test/e2e/global-setup.ts', import.meta.url)), - globalTeardown: fileURLToPath(new URL('./test/e2e/global-teardown.ts', import.meta.url)), // We currently only test on one browser on one platform snapshotPathTemplate: '{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{ext}', use: { diff --git a/test/e2e/connector.spec.ts b/test/e2e/connector.spec.ts index 4f7f760bc..bcfca4f82 100644 --- a/test/e2e/connector.spec.ts +++ b/test/e2e/connector.spec.ts @@ -3,11 +3,16 @@ * * These tests use a mock connector server (started in global setup) * to test features that require being logged in via the connector. + * + * All tests run serially because they share a single mock connector server + * whose state is reset before each test via `mockConnector.reset()`. */ import type { Page } from '@playwright/test' import { test, expect } from './helpers/fixtures' +test.describe.configure({ mode: 'serial' }) + /** * When connected, the header shows "packages" and "orgs" links scoped to the user. * This helper waits for the packages link to appear as proof of successful connection. diff --git a/test/e2e/global-setup.ts b/test/e2e/global-setup.ts index 6877afc2a..ae6b884b6 100644 --- a/test/e2e/global-setup.ts +++ b/test/e2e/global-setup.ts @@ -1,25 +1,33 @@ /** * Playwright global setup - starts the mock connector server before all tests. + * + * Returns an async teardown function (Playwright's recommended pattern for + * sharing state between setup and teardown via closure). */ 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) + const 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 } + + // Return teardown function — Playwright calls this after all tests complete + return async () => { + console.log('[Global Teardown] Stopping mock connector server...') + try { + await mockServer.stop() + } catch (error) { + console.error('[Global Teardown] Error stopping mock connector:', error) + } + } } diff --git a/test/e2e/global-teardown.ts b/test/e2e/global-teardown.ts deleted file mode 100644 index ad44062f8..000000000 --- a/test/e2e/global-teardown.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * 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/test/e2e/helpers/fixtures.ts b/test/e2e/helpers/fixtures.ts index a169f8df8..3d0fd049a 100644 --- a/test/e2e/helpers/fixtures.ts +++ b/test/e2e/helpers/fixtures.ts @@ -81,20 +81,33 @@ export class MockConnectorClient { ...options?.headers, }, }) + if (!response.ok) { + throw new Error( + `Mock connector request failed: ${response.status} ${response.statusText} (${path})`, + ) + } 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`, { + /** Make a fire-and-forget POST to a test-only endpoint, throwing on failure. */ + private async testEndpoint(path: string, body: unknown): Promise { + const response = await fetch(`${this.baseUrl}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token: this.token }), + body: JSON.stringify(body), }) + if (!response.ok) { + throw new Error( + `Mock connector test endpoint failed: ${response.status} ${response.statusText} (${path})`, + ) + } + } + + /** Reset the mock connector state */ + async reset(): Promise { + await this.testEndpoint('/__test__/reset', {}) + // Connect to establish session + await this.testEndpoint('/connect', { token: this.token }) } /** Set org data */ @@ -106,29 +119,17 @@ export class MockConnectorClient { teamMembers?: Record }, ): Promise { - await fetch(`${this.baseUrl}/__test__/org`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ org, ...data }), - }) + await this.testEndpoint('/__test__/org', { 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 }), - }) + await this.testEndpoint('/__test__/user-orgs', { 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 }), - }) + await this.testEndpoint('/__test__/user-packages', { packages }) } /** Set package data */ @@ -136,11 +137,7 @@ export class MockConnectorClient { 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 }), - }) + await this.testEndpoint('/__test__/package', { package: pkg, ...data }) } /** Add an operation */ diff --git a/test/e2e/helpers/mock-connector.ts b/test/e2e/helpers/mock-connector.ts index 154c8598a..b2cdd1ae4 100644 --- a/test/e2e/helpers/mock-connector.ts +++ b/test/e2e/helpers/mock-connector.ts @@ -523,6 +523,7 @@ export class MockConnectorServer { const port = this.stateManager.port this.server!.on('error', (err: NodeJS.ErrnoException) => { + this.server = null if (err.code === 'EADDRINUSE') { reject(new Error(`Port ${port} is already in use. Is the real connector running?`)) } else { From b4113e486e9e32ac883b126927bd3176e350ba58 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Tue, 10 Feb 2026 15:00:51 +0000 Subject: [PATCH 07/11] test: fix flaky add-member test timing on CI --- test/e2e/connector.spec.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/test/e2e/connector.spec.ts b/test/e2e/connector.spec.ts index bcfca4f82..61876fa24 100644 --- a/test/e2e/connector.spec.ts +++ b/test/e2e/connector.spec.ts @@ -170,12 +170,17 @@ test.describe('Organization Management', () => { // Click "Add member" button await orgManagement.getByRole('button', { name: /add member/i }).click() - // Fill in the form + // Wait for the add-member form to appear const usernameInput = orgManagement.locator('input[name="new-member-username"]') + await expect(usernameInput).toBeVisible({ timeout: 5000 }) + + // Fill in the form await usernameInput.fill('newuser') // Select role - await orgManagement.locator('select[name="new-member-role"]').selectOption('admin') + const roleSelect = orgManagement.locator('select[name="new-member-role"]') + await expect(roleSelect).toBeVisible({ timeout: 5000 }) + await roleSelect.selectOption('admin') // Submit await orgManagement.getByRole('button', { name: /^add$/i }).click() From c039fd0d375cabda5cfad3f46f4609e4c3aee421 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Tue, 10 Feb 2026 16:27:04 +0000 Subject: [PATCH 08/11] test: update selectors --- test/e2e/connector.spec.ts | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/test/e2e/connector.spec.ts b/test/e2e/connector.spec.ts index 61876fa24..6504e50bd 100644 --- a/test/e2e/connector.spec.ts +++ b/test/e2e/connector.spec.ts @@ -171,16 +171,14 @@ test.describe('Organization Management', () => { await orgManagement.getByRole('button', { name: /add member/i }).click() // Wait for the add-member form to appear - const usernameInput = orgManagement.locator('input[name="new-member-username"]') + const usernameInput = orgManagement.locator('#new-member-username') await expect(usernameInput).toBeVisible({ timeout: 5000 }) // Fill in the form await usernameInput.fill('newuser') - // Select role - const roleSelect = orgManagement.locator('select[name="new-member-role"]') - await expect(roleSelect).toBeVisible({ timeout: 5000 }) - await roleSelect.selectOption('admin') + // Select role (SelectField renders id on the