diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 858a951e5..6fc688264 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,6 +33,7 @@ This focus helps guide our project decisions as a community and what we choose t - [Available commands](#available-commands) - [Project structure](#project-structure) - [Local connector CLI](#local-connector-cli) + - [Mock connector (for local development)](#mock-connector-for-local-development) - [Code style](#code-style) - [TypeScript](#typescript) - [Server API patterns](#server-api-patterns) @@ -104,6 +105,10 @@ pnpm dev # Start development server pnpm build # Production build pnpm preview # Preview production build +# Connector +pnpm npmx-connector # Start the real connector (requires npm login) +pnpm mock-connector # Start the mock connector (no npm login needed) + # Code Quality pnpm lint # Run linter (oxlint + oxfmt) pnpm lint:fix # Auto-fix lint issues @@ -157,6 +162,36 @@ pnpm npmx-connector The connector will check your npm authentication, generate a connection token, and listen for requests from npmx.dev. +### Mock connector (for local development) + +If you're working on admin features (org management, package access controls, operations queue) and don't want to use your real npm account, you can run the mock connector instead: + +```bash +pnpm mock-connector +``` + +This starts a mock connector server pre-populated with sample data (orgs, teams, members, packages). No npm login is required — operations succeed immediately without making real npm CLI calls. + +The mock connector prints a connection URL to the terminal, just like the real connector. Click it (or paste the token manually) to connect the UI. + +**Options:** + +```bash +pnpm mock-connector # default: port 31415, user "mock-user", sample data +pnpm mock-connector --port 9999 # custom port +pnpm mock-connector --user alice # custom username +pnpm mock-connector --empty # start with no pre-populated data +``` + +**Default sample data:** + +- **@nuxt**: 4 members (mock-user, danielroe, pi0, antfu), 3 teams (core, docs, triage) +- **@unjs**: 2 members (mock-user, pi0), 1 team (maintainers) +- **Packages**: @nuxt/kit, @nuxt/schema, @unjs/nitro with team-based access controls + +> [!TIP] +> Run `pnpm dev` in a separate terminal to start the Nuxt dev server, then click the connection URL from the mock connector to connect. + ## Code style When committing changes, try to keep an eye out for unintended formatting updates. These can make a pull request look noisier than it really is and slow down the review process. Sometimes IDEs automatically reformat files on save, which can unintentionally introduce extra changes. @@ -752,6 +787,74 @@ You need to either: 1. Add a fixture file for that package/endpoint 2. Update the mock handlers in `test/fixtures/mock-routes.cjs` (client) or `modules/runtime/server/cache.ts` (server) +### Testing connector features + +Features that require authentication through the local connector (org management, package collaborators, operations queue) are tested using a mock connector server. + +#### Architecture + +The mock connector infrastructure is shared between the CLI, E2E tests, and Vitest component tests: + +``` +cli/src/ +├── types.ts # ConnectorEndpoints contract (shared by real + mock) +├── mock-state.ts # MockConnectorStateManager (canonical source) +├── mock-app.ts # H3 mock app + MockConnectorServer class +└── mock-server.ts # CLI entry point (pnpm mock-connector) + +test/test-utils/ # Re-exports from cli/src/ for test convenience +test/e2e/helpers/ # E2E-specific wrappers (fixtures, global setup) +``` + +Both the real server (`cli/src/server.ts`) and the mock server (`cli/src/mock-app.ts`) conform to the `ConnectorEndpoints` interface defined in `cli/src/types.ts`. This ensures the API contract is enforced by TypeScript. When adding a new endpoint, update `ConnectorEndpoints` first, then implement it in both servers. + +#### Vitest component tests (`test/nuxt/`) + +- Mock the `useConnector` composable with reactive state +- Use `document.body` queries for components using Teleport +- See `test/nuxt/components/HeaderConnectorModal.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 + }), +})) +``` + +#### Playwright E2E tests (`test/e2e/`) + +- A mock HTTP 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__/reset` - Reset all mock state +- `/__test__/org` - Set org users, teams, and team members +- `/__test__/user-orgs` - Set user's organizations +- `/__test__/user-packages` - Set user's packages +- `/__test__/package` - Set package collaborators + ## Submitting changes ### Before submitting diff --git a/app/composables/useConnector.ts b/app/composables/useConnector.ts index 8efe83114..00c3e998d 100644 --- a/app/composables/useConnector.ts +++ b/app/composables/useConnector.ts @@ -1,4 +1,4 @@ -import type { PendingOperation, OperationStatus, OperationType } from '../../cli/src/types' +import type { PendingOperation, OperationStatus, OperationType } from '#cli/types' import { $fetch } from 'ofetch' export interface NewOperation { diff --git a/cli/package.json b/cli/package.json index f95122382..0f6276443 100644 --- a/cli/package.json +++ b/cli/package.json @@ -17,10 +17,7 @@ "npmx-connector": "./dist/cli.mjs" }, "exports": { - ".": { - "import": "./dist/index.mjs", - "types": "./dist/index.d.mts" - } + ".": "./dist/index.mjs" }, "files": [ "dist" @@ -29,6 +26,7 @@ "build": "tsdown", "dev": "NPMX_CLI_DEV=true node src/cli.ts", "dev:debug": "DEBUG=npmx-connector NPMX_CLI_DEV=true node src/cli.ts", + "dev:mock": "NPMX_CLI_DEV=true node src/mock-server.ts", "test:types": "tsc --noEmit" }, "dependencies": { diff --git a/cli/src/mock-app.ts b/cli/src/mock-app.ts new file mode 100644 index 000000000..e3bdc9052 --- /dev/null +++ b/cli/src/mock-app.ts @@ -0,0 +1,467 @@ +/** + * Mock connector H3 application. Same API as the real server (server.ts) + * but backed by in-memory state. Used by the mock CLI and E2E tests. + */ + +import { H3, HTTPError, handleCors, type H3Event } from 'h3-next' +import type { CorsOptions } from 'h3-next' +import { serve, type Server } from 'srvx' +import type { + OperationType, + ApiResponse, + ConnectorEndpoints, + AssertEndpointsImplemented, +} from './types.ts' +import type { MockConnectorStateManager } from './mock-state.ts' + +// Endpoint completeness check — errors if this list diverges from ConnectorEndpoints. +// oxlint-disable-next-line no-unused-vars +const _endpointCheck: AssertEndpointsImplemented< + | 'POST /connect' + | 'GET /state' + | 'POST /operations' + | 'POST /operations/batch' + | 'DELETE /operations' + | 'DELETE /operations/all' + | 'POST /approve' + | 'POST /approve-all' + | 'POST /retry' + | 'POST /execute' + | 'GET /org/:org/users' + | 'GET /org/:org/teams' + | 'GET /team/:scopeTeam/users' + | 'GET /package/:pkg/collaborators' + | 'GET /user/packages' + | 'GET /user/orgs' +> = true +void _endpointCheck + +const corsOptions: CorsOptions = { + origin: '*', + methods: ['GET', 'POST', 'DELETE', 'OPTIONS'], + allowHeaders: ['Content-Type', 'Authorization'], +} + +function createMockConnectorApp(stateManager: MockConnectorStateManager) { + const app = new H3() + + app.use((event: H3Event) => { + const corsResult = handleCors(event, corsOptions) + if (corsResult !== false) { + return corsResult + } + }) + + function requireAuth(event: H3Event): void { + const authHeader = event.req.headers.get('authorization') + if (!authHeader || !authHeader.startsWith('Bearer ')) { + throw new HTTPError({ statusCode: 401, message: 'Authorization required' }) + } + const token = authHeader.slice(7) + if (token !== stateManager.token) { + throw new HTTPError({ statusCode: 401, message: 'Invalid token' }) + } + if (!stateManager.isConnected()) { + throw new HTTPError({ statusCode: 401, message: 'Not connected' }) + } + } + + // POST /connect + app.post('/connect', async (event: H3Event) => { + const body = (await event.req.json()) as { token?: string } + const token = body?.token + + if (!token || token !== stateManager.token) { + throw new HTTPError({ statusCode: 401, message: 'Invalid token' }) + } + + stateManager.connect(token) + + return { + success: true, + data: { + npmUser: stateManager.config.npmUser, + avatar: stateManager.config.avatar ?? null, + connectedAt: stateManager.state.connectedAt ?? Date.now(), + }, + } satisfies ApiResponse + }) + + // GET /state + app.get('/state', (event: H3Event) => { + requireAuth(event) + + return { + success: true, + data: { + npmUser: stateManager.config.npmUser, + avatar: stateManager.config.avatar ?? null, + operations: stateManager.getOperations(), + }, + } satisfies ApiResponse + }) + + // POST /operations + app.post('/operations', async (event: H3Event) => { + requireAuth(event) + + const body = (await event.req.json()) as { + type?: string + params?: Record + description?: string + command?: string + dependsOn?: string + } + if (!body?.type || !body.description || !body.command) { + throw new HTTPError({ statusCode: 400, message: '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, + } satisfies ApiResponse + }) + + // POST /operations/batch + app.post('/operations/batch', async (event: H3Event) => { + requireAuth(event) + + const body = await event.req.json() + if (!Array.isArray(body)) { + throw new HTTPError({ statusCode: 400, message: 'Expected array of operations' }) + } + + const operations = stateManager.addOperations(body) + return { + success: true, + data: operations, + } satisfies ApiResponse + }) + + // DELETE /operations?id= + app.delete('/operations', (event: H3Event) => { + requireAuth(event) + + const id = new URL(event.req.url).searchParams.get('id') + if (!id) { + throw new HTTPError({ statusCode: 400, message: 'Missing operation id' }) + } + + const removed = stateManager.removeOperation(id) + if (!removed) { + throw new HTTPError({ statusCode: 404, message: 'Operation not found or cannot be removed' }) + } + + return { success: true } satisfies ApiResponse + }) + + // DELETE /operations/all + app.delete('/operations/all', (event: H3Event) => { + requireAuth(event) + + const removed = stateManager.clearOperations() + return { + success: true, + data: { removed }, + } satisfies ApiResponse + }) + + // POST /approve?id= + app.post('/approve', (event: H3Event) => { + requireAuth(event) + + const id = new URL(event.req.url).searchParams.get('id') + if (!id) { + throw new HTTPError({ statusCode: 400, message: 'Missing operation id' }) + } + + const operation = stateManager.approveOperation(id) + if (!operation) { + throw new HTTPError({ statusCode: 404, message: 'Operation not found or not pending' }) + } + + return { + success: true, + data: operation, + } satisfies ApiResponse + }) + + // POST /approve-all + app.post('/approve-all', (event: H3Event) => { + requireAuth(event) + + const approved = stateManager.approveAll() + return { + success: true, + data: { approved }, + } satisfies ApiResponse + }) + + // POST /retry?id= + app.post('/retry', (event: H3Event) => { + requireAuth(event) + + const id = new URL(event.req.url).searchParams.get('id') + if (!id) { + throw new HTTPError({ statusCode: 400, message: 'Missing operation id' }) + } + + const operation = stateManager.retryOperation(id) + if (!operation) { + throw new HTTPError({ statusCode: 404, message: 'Operation not found or not failed' }) + } + + return { + success: true, + data: operation, + } satisfies ApiResponse + }) + + // POST /execute + app.post('/execute', async (event: H3Event) => { + requireAuth(event) + + const body = await event.req.json().catch(() => ({})) + const otp = (body as { otp?: string })?.otp + + const { results, otpRequired } = stateManager.executeOperations({ otp }) + + return { + success: true, + data: { results, otpRequired }, + } satisfies ApiResponse + }) + + // GET /org/:org/users + app.get('/org/:org/users', (event: H3Event) => { + requireAuth(event) + + const org = event.context.params?.org + if (!org) { + throw new HTTPError({ statusCode: 400, message: 'Missing org parameter' }) + } + + const normalizedOrg = org.startsWith('@') ? org : `@${org}` + const users = stateManager.getOrgUsers(normalizedOrg) + if (users === null) { + return { success: true, data: {} } satisfies ApiResponse< + ConnectorEndpoints['GET /org/:org/users']['data'] + > + } + + return { success: true, data: users } satisfies ApiResponse< + ConnectorEndpoints['GET /org/:org/users']['data'] + > + }) + + // GET /org/:org/teams + app.get('/org/:org/teams', (event: H3Event) => { + requireAuth(event) + + const org = event.context.params?.org + if (!org) { + throw new HTTPError({ statusCode: 400, message: 'Missing org parameter' }) + } + + const normalizedOrg = org.startsWith('@') ? org : `@${org}` + const orgName = normalizedOrg.slice(1) + + const teams = stateManager.getOrgTeams(normalizedOrg) + const formattedTeams = teams ? teams.map(t => `${orgName}:${t}`) : [] + return { success: true, data: formattedTeams } satisfies ApiResponse< + ConnectorEndpoints['GET /org/:org/teams']['data'] + > + }) + + // GET /team/:scopeTeam/users + app.get('/team/:scopeTeam/users', (event: H3Event) => { + requireAuth(event) + + const scopeTeam = event.context.params?.scopeTeam + if (!scopeTeam) { + throw new HTTPError({ statusCode: 400, message: 'Missing scopeTeam parameter' }) + } + + if (!scopeTeam.startsWith('@') || !scopeTeam.includes(':')) { + throw new HTTPError({ + statusCode: 400, + message: 'Invalid scope:team format (expected @scope:team)', + }) + } + + const [scope, team] = scopeTeam.split(':') + if (!scope || !team) { + throw new HTTPError({ statusCode: 400, message: 'Invalid scope:team format' }) + } + + const users = stateManager.getTeamUsers(scope, team) + return { success: true, data: users ?? [] } satisfies ApiResponse< + ConnectorEndpoints['GET /team/:scopeTeam/users']['data'] + > + }) + + // GET /package/:pkg/collaborators + app.get('/package/:pkg/collaborators', (event: H3Event) => { + requireAuth(event) + + const pkg = event.context.params?.pkg + if (!pkg) { + throw new HTTPError({ statusCode: 400, message: 'Missing package parameter' }) + } + + const collaborators = stateManager.getPackageCollaborators(decodeURIComponent(pkg)) + return { success: true, data: collaborators ?? {} } satisfies ApiResponse< + ConnectorEndpoints['GET /package/:pkg/collaborators']['data'] + > + }) + + // GET /user/packages + app.get('/user/packages', (event: H3Event) => { + requireAuth(event) + + const packages = stateManager.getUserPackages() + return { success: true, data: packages } satisfies ApiResponse< + ConnectorEndpoints['GET /user/packages']['data'] + > + }) + + // GET /user/orgs + app.get('/user/orgs', (event: H3Event) => { + requireAuth(event) + + const orgs = stateManager.getUserOrgs() + return { success: true, data: orgs } satisfies ApiResponse< + ConnectorEndpoints['GET /user/orgs']['data'] + > + }) + + // -- Test-only endpoints -- + + // POST /__test__/reset + app.post('/__test__/reset', () => { + stateManager.reset() + return { success: true } + }) + + // POST /__test__/org + app.post('/__test__/org', async (event: H3Event) => { + const body = (await event.req.json()) as { + org?: string + users?: Record + teams?: string[] + teamMembers?: Record + } + if (!body?.org) { + throw new HTTPError({ statusCode: 400, message: 'Missing org parameter' }) + } + + stateManager.setOrgData(body.org, { + users: body.users, + teams: body.teams, + teamMembers: body.teamMembers, + }) + + return { success: true } + }) + + // POST /__test__/user-orgs + app.post('/__test__/user-orgs', async (event: H3Event) => { + const body = (await event.req.json()) as { orgs?: string[] } + if (!body?.orgs) { + throw new HTTPError({ statusCode: 400, message: 'Missing orgs parameter' }) + } + + stateManager.setUserOrgs(body.orgs) + return { success: true } + }) + + // POST /__test__/user-packages + app.post('/__test__/user-packages', async (event: H3Event) => { + const body = (await event.req.json()) as { + packages?: Record + } + if (!body?.packages) { + throw new HTTPError({ statusCode: 400, message: 'Missing packages parameter' }) + } + + stateManager.setUserPackages(body.packages) + return { success: true } + }) + + // POST /__test__/package + app.post('/__test__/package', async (event: H3Event) => { + const body = (await event.req.json()) as { + package?: string + collaborators?: Record + } + if (!body?.package) { + throw new HTTPError({ statusCode: 400, message: 'Missing package parameter' }) + } + + stateManager.setPackageData(body.package, { + collaborators: body.collaborators ?? {}, + }) + + return { success: true } + }) + + return app +} + +/** Wraps the mock H3 app in an HTTP server via srvx. */ +export class MockConnectorServer { + private server: Server | null = null + private stateManager: MockConnectorStateManager + + constructor(stateManager: MockConnectorStateManager) { + this.stateManager = stateManager + } + + async start(): Promise { + if (this.server) { + throw new Error('Mock connector server is already running') + } + + const app = createMockConnectorApp(this.stateManager) + + this.server = serve({ + port: this.stateManager.port, + hostname: '127.0.0.1', + fetch: app.fetch, + }) + + await this.server.ready() + console.log(`[Mock Connector] Started on http://127.0.0.1:${this.stateManager.port}`) + } + + async stop(): Promise { + if (!this.server) return + await this.server.close() + console.log('[Mock Connector] Stopped') + this.server = null + } + + get state(): MockConnectorStateManager { + return this.stateManager + } + + get port(): number { + return this.stateManager.port + } + + get token(): string { + return this.stateManager.token + } + + reset(): void { + this.stateManager.reset() + } +} diff --git a/cli/src/mock-server.ts b/cli/src/mock-server.ts new file mode 100644 index 000000000..2f9bab3a9 --- /dev/null +++ b/cli/src/mock-server.ts @@ -0,0 +1,168 @@ +#!/usr/bin/env node +/** + * Mock connector CLI — starts a pre-populated mock server for developing + * authenticated features without a real npm account. + */ + +import process from 'node:process' +import crypto from 'node:crypto' +import { styleText } from 'node:util' +import * as p from '@clack/prompts' +import { defineCommand, runMain } from 'citty' +import { + MockConnectorStateManager, + createMockConnectorState, + type MockConnectorConfig, +} from './mock-state.ts' +import { MockConnectorServer } from './mock-app.ts' + +const DEFAULT_PORT = 31415 +const DEV_FRONTEND_URL = 'http://127.0.0.1:3000/' +const PROD_FRONTEND_URL = 'https://npmx.dev/' + +function generateToken(): string { + return crypto.randomBytes(16).toString('hex') +} + +/** + * Pre-populate with sample data using real npm orgs so the registry + * API calls don't 404. Members/teams are fictional. + */ +function populateDefaultData(stateManager: MockConnectorStateManager): void { + const npmUser = stateManager.config.npmUser + + stateManager.setOrgData('@nuxt', { + users: { + [npmUser]: 'owner', + danielroe: 'owner', + pi0: 'admin', + antfu: 'developer', + }, + teams: ['core', 'docs', 'triage'], + teamMembers: { + core: [npmUser, 'danielroe', 'pi0'], + docs: ['antfu'], + triage: ['pi0', 'antfu'], + }, + }) + + stateManager.setOrgData('@unjs', { + users: { + [npmUser]: 'admin', + pi0: 'owner', + }, + teams: ['maintainers'], + teamMembers: { + maintainers: [npmUser, 'pi0'], + }, + }) + + stateManager.setUserOrgs(['nuxt', 'unjs']) + + stateManager.setPackageData('@nuxt/kit', { + collaborators: { + [npmUser]: 'read-write', + 'danielroe': 'read-write', + 'nuxt:core': 'read-write', + 'nuxt:docs': 'read-only', + }, + }) + stateManager.setPackageData('@nuxt/schema', { + collaborators: { + [npmUser]: 'read-write', + 'nuxt:core': 'read-write', + }, + }) + stateManager.setPackageData('@unjs/nitro', { + collaborators: { + [npmUser]: 'read-write', + 'pi0': 'read-write', + 'unjs:maintainers': 'read-write', + }, + }) + + stateManager.setUserPackages({ + '@nuxt/kit': 'read-write', + '@nuxt/schema': 'read-write', + '@unjs/nitro': 'read-write', + }) +} + +const main = defineCommand({ + meta: { + name: 'npmx-connector-mock', + version: '0.0.1', + description: 'Mock connector for npmx.dev development and testing', + }, + args: { + port: { + type: 'string', + description: 'Port to listen on', + default: String(DEFAULT_PORT), + }, + user: { + type: 'string', + description: 'Simulated npm username', + default: 'mock-user', + }, + empty: { + type: 'boolean', + description: 'Start with empty state (no pre-populated data)', + default: false, + }, + }, + async run({ args }) { + const port = Number.parseInt(args.port as string, 10) || DEFAULT_PORT + const npmUser = args.user as string + const empty = args.empty as boolean + const frontendUrl = process.env.NPMX_CLI_DEV === 'true' ? DEV_FRONTEND_URL : PROD_FRONTEND_URL + + p.intro(styleText(['bgMagenta', 'white'], ' npmx mock connector ')) + + const token = generateToken() + const config: MockConnectorConfig = { + token, + npmUser, + avatar: null, + port, + } + const stateManager = new MockConnectorStateManager(createMockConnectorState(config)) + + if (!empty) { + populateDefaultData(stateManager) + p.log.info(`Pre-populated with sample data for ${styleText('cyan', npmUser)}`) + p.log.info(styleText('dim', ` Orgs: @nuxt (4 members, 3 teams), @unjs (2 members, 1 team)`)) + p.log.info(styleText('dim', ` Packages: @nuxt/kit, @nuxt/schema, @unjs/nitro`)) + } else { + p.log.info('Starting with empty state') + } + + stateManager.connect(token) + const server = new MockConnectorServer(stateManager) + + try { + await server.start() + } catch (error) { + p.log.error(error instanceof Error ? error.message : 'Failed to start mock connector server') + process.exit(1) + } + + const connectUrl = `${frontendUrl}?token=${token}&port=${port}` + + p.note( + [ + `Open: ${styleText(['bold', 'underline', 'cyan'], connectUrl)}`, + '', + styleText('dim', `Or paste token manually: ${token}`), + '', + styleText('dim', `User: ${npmUser} | Port: ${port}`), + styleText('dim', 'Operations will succeed immediately (no real npm calls)'), + ].join('\n'), + 'Click to connect', + ) + + p.log.info('Waiting for connection... (Press Ctrl+C to stop)') + }, +}) + +runMain(main) diff --git a/cli/src/mock-state.ts b/cli/src/mock-state.ts new file mode 100644 index 000000000..b2182fab5 --- /dev/null +++ b/cli/src/mock-state.ts @@ -0,0 +1,526 @@ +/** + * Mock connector state management. Canonical source used by the mock server, + * E2E tests, and Vitest composable mocks. + */ + +import type { + PendingOperation, + OperationType, + OperationResult, + OrgRole, + AccessPermission, +} from './types.ts' + +export interface MockConnectorConfig { + token: string + npmUser: string + avatar?: string | null + port?: number +} + +export interface MockOrgData { + users: Record + teams: string[] + /** team name -> member usernames */ + teamMembers: Record +} + +export interface MockPackageData { + collaborators: Record +} + +export interface MockConnectorStateData { + config: MockConnectorConfig + connected: boolean + connectedAt: number | null + orgs: Record + packages: Record + userPackages: Record + userOrgs: string[] + operations: PendingOperation[] + operationIdCounter: number +} + +export interface NewOperationInput { + type: OperationType + params: Record + description: string + command: string + dependsOn?: string +} + +export interface ExecuteOptions { + otp?: string + /** Per-operation results for testing failures. */ + results?: Record> +} + +export interface ExecuteResult { + results: Array<{ id: string; result: OperationResult }> + otpRequired?: boolean +} + +export function createMockConnectorState(config: MockConnectorConfig): MockConnectorStateData { + return { + config: { + port: 31415, + avatar: null, + ...config, + }, + connected: false, + connectedAt: null, + orgs: {}, + packages: {}, + userPackages: {}, + userOrgs: [], + operations: [], + operationIdCounter: 0, + } +} + +/** + * Mock connector state, shared between the 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 { + 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 { + 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 + } + + /** Execute all approved operations (mock: instant success unless configured otherwise). */ + executeOperations(options?: ExecuteOptions): ExecuteResult { + const results: Array<{ id: string; result: 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({ id: op.id, 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({ id: op.id, result }) + + // Apply the operation's effects to mock state + this.applyOperationEffect(op) + } + } + + return { results } + } + + /** Apply side effects of a completed operation. Param keys match schemas.ts. */ + private applyOperationEffect(op: PendingOperation): void { + const { type, params } = op + + switch (type) { + case 'org:add-user': { + // Params: { org, user, role } — OrgAddUserParamsSchema + 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': { + // Params: { org, user } — OrgRemoveUserParamsSchema + 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': { + // Params: { org, user, role } — reuses OrgAddUserParamsSchema + 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': { + // Params: { scopeTeam } — TeamCreateParamsSchema + const scopeTeam = params['scopeTeam'] + if (scopeTeam) { + const [scope, team] = scopeTeam.split(':') + if (scope && team) { + const normalizedScope = scope.startsWith('@') ? scope : `@${scope}` + if (!this.state.orgs[normalizedScope]) { + this.state.orgs[normalizedScope] = { users: {}, teams: [], teamMembers: {} } + } + if (!this.state.orgs[normalizedScope].teams.includes(team)) { + this.state.orgs[normalizedScope].teams.push(team) + } + this.state.orgs[normalizedScope].teamMembers[team] = [] + } + } + break + } + case 'team:destroy': { + // Params: { scopeTeam } — TeamDestroyParamsSchema + const scopeTeam = params['scopeTeam'] + if (scopeTeam) { + const [scope, team] = scopeTeam.split(':') + if (scope && team) { + const normalizedScope = scope.startsWith('@') ? scope : `@${scope}` + if (this.state.orgs[normalizedScope]) { + this.state.orgs[normalizedScope].teams = this.state.orgs[ + normalizedScope + ].teams.filter(t => t !== team) + delete this.state.orgs[normalizedScope].teamMembers[team] + } + } + } + break + } + case 'team:add-user': { + // Params: { scopeTeam, user } — TeamAddUserParamsSchema + 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': { + // Params: { scopeTeam, user } — TeamRemoveUserParamsSchema + 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': { + // Params: { permission, scopeTeam, pkg } — AccessGrantParamsSchema + const pkg = params['pkg'] + const scopeTeam = params['scopeTeam'] + const permission = (params['permission'] as AccessPermission) ?? 'read-write' + if (pkg && scopeTeam) { + if (!this.state.packages[pkg]) { + this.state.packages[pkg] = { collaborators: {} } + } + this.state.packages[pkg].collaborators[scopeTeam] = permission + } + break + } + case 'access:revoke': { + // Params: { scopeTeam, pkg } — AccessRevokeParamsSchema + const pkg = params['pkg'] + const scopeTeam = params['scopeTeam'] + if (pkg && scopeTeam && this.state.packages[pkg]) { + delete this.state.packages[pkg].collaborators[scopeTeam] + } + break + } + case 'owner:add': { + // Params: { user, pkg } — OwnerAddParamsSchema + const pkg = params['pkg'] + 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': { + // Params: { user, pkg } — OwnerRemoveParamsSchema + const pkg = params['pkg'] + const user = params['user'] + if (pkg && user && this.state.packages[pkg]) { + delete this.state.packages[pkg].collaborators[user] + } + break + } + case 'package:init': { + // Params: { name, author? } — PackageInitParamsSchema + const name = params['name'] + if (name) { + this.state.packages[name] = { + collaborators: { [this.state.config.npmUser]: 'read-write' }, + } + this.state.userPackages[name] = 'read-write' + } + break + } + } + } + + /** Topological sort by dependsOn. */ + 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(): 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 + } +} + +/** @internal */ +export const DEFAULT_MOCK_CONFIG: MockConnectorConfig = { + token: 'test-token-e2e-12345', + npmUser: 'testuser', + avatar: null, + port: 31415, +} diff --git a/cli/src/server.ts b/cli/src/server.ts index fc609e06f..d750f7f61 100644 --- a/cli/src/server.ts +++ b/cli/src/server.ts @@ -3,7 +3,34 @@ import { H3, HTTPError, handleCors, type H3Event } from 'h3-next' import type { CorsOptions } from 'h3-next' import * as v from 'valibot' -import type { ConnectorState, PendingOperation, ApiResponse } from './types.ts' +import type { + ConnectorState, + PendingOperation, + ApiResponse, + ConnectorEndpoints, + AssertEndpointsImplemented, +} from './types.ts' + +// Endpoint completeness check — errors if this list diverges from ConnectorEndpoints. +const _endpointCheck: AssertEndpointsImplemented< + | 'POST /connect' + | 'GET /state' + | 'POST /operations' + | 'POST /operations/batch' + | 'DELETE /operations' + | 'DELETE /operations/all' + | 'POST /approve' + | 'POST /approve-all' + | 'POST /retry' + | 'POST /execute' + | 'GET /org/:org/users' + | 'GET /org/:org/teams' + | 'GET /team/:scopeTeam/users' + | 'GET /package/:pkg/collaborators' + | 'GET /user/packages' + | 'GET /user/orgs' +> = true +void _endpointCheck import { logDebug, logError } from './logger.ts' import { getNpmUser, @@ -108,7 +135,7 @@ export function createConnectorApp(expectedToken: string) { avatar, connectedAt: state.session.connectedAt, }, - } as ApiResponse + } satisfies ApiResponse }) app.get('/state', event => { @@ -124,7 +151,7 @@ export function createConnectorApp(expectedToken: string) { avatar: state.session.avatar, operations: state.operations, }, - } as ApiResponse + } satisfies ApiResponse }) app.post('/operations', async event => { @@ -164,7 +191,7 @@ export function createConnectorApp(expectedToken: string) { return { success: true, data: operation, - } as ApiResponse + } satisfies ApiResponse }) app.post('/operations/batch', async event => { @@ -212,7 +239,7 @@ export function createConnectorApp(expectedToken: string) { return { success: true, data: created, - } as ApiResponse + } satisfies ApiResponse }) app.post('/approve', event => { @@ -246,7 +273,7 @@ export function createConnectorApp(expectedToken: string) { return { success: true, data: operation, - } as ApiResponse + } satisfies ApiResponse }) app.post('/approve-all', event => { @@ -263,7 +290,7 @@ export function createConnectorApp(expectedToken: string) { return { success: true, data: { approved: pendingOps.length }, - } as ApiResponse + } satisfies ApiResponse }) app.post('/retry', event => { @@ -299,7 +326,7 @@ export function createConnectorApp(expectedToken: string) { return { success: true, data: operation, - } as ApiResponse + } satisfies ApiResponse }) app.post('/execute', async event => { @@ -397,7 +424,7 @@ export function createConnectorApp(expectedToken: string) { otpRequired, authFailure, }, - } as ApiResponse + } satisfies ApiResponse }) app.delete('/operations', event => { @@ -429,7 +456,7 @@ export function createConnectorApp(expectedToken: string) { state.operations.splice(index, 1) - return { success: true } as ApiResponse + return { success: true } satisfies ApiResponse }) app.delete('/operations/all', event => { @@ -444,7 +471,7 @@ export function createConnectorApp(expectedToken: string) { return { success: true, data: { removed }, - } as ApiResponse + } satisfies ApiResponse }) // List endpoints (read-only data fetching) @@ -474,7 +501,7 @@ export function createConnectorApp(expectedToken: string) { return { success: true, data: users, - } as ApiResponse + } satisfies ApiResponse } catch { return { success: false, @@ -508,7 +535,7 @@ export function createConnectorApp(expectedToken: string) { return { success: true, data: teams, - } as ApiResponse + } satisfies ApiResponse } catch { return { success: false, @@ -554,7 +581,7 @@ export function createConnectorApp(expectedToken: string) { return { success: true, data: users, - } as ApiResponse + } satisfies ApiResponse } catch { return { success: false, @@ -595,7 +622,7 @@ export function createConnectorApp(expectedToken: string) { return { success: true, data: collaborators, - } as ApiResponse + } satisfies ApiResponse } catch { return { success: false, @@ -634,7 +661,7 @@ export function createConnectorApp(expectedToken: string) { return { success: true, data: packages, - } as ApiResponse + } satisfies ApiResponse } catch { return { success: false, @@ -686,7 +713,7 @@ export function createConnectorApp(expectedToken: string) { return { success: true, data: Array.from(orgs).sort(), - } as ApiResponse + } satisfies ApiResponse } catch { return { success: false, diff --git a/cli/src/types.ts b/cli/src/types.ts index a83e9f3b7..f9efcbc8b 100644 --- a/cli/src/types.ts +++ b/cli/src/types.ts @@ -66,3 +66,83 @@ export interface ApiResponse { data?: T error?: string } + +// -- Connector API contract (shared by real + mock server) ------------------- + +export type OrgRole = 'developer' | 'admin' | 'owner' + +export type AccessPermission = 'read-only' | 'read-write' + +/** POST /connect response data */ +export interface ConnectResponseData { + npmUser: string | null + avatar: string | null + connectedAt: number +} + +/** GET /state response data */ +export interface StateResponseData { + npmUser: string | null + avatar: string | null + operations: PendingOperation[] +} + +/** POST /execute response data */ +export interface ExecuteResponseData { + results: Array<{ id: string; result: OperationResult }> + otpRequired?: boolean + authFailure?: boolean +} + +/** POST /approve-all response data */ +export interface ApproveAllResponseData { + approved: number +} + +/** DELETE /operations/all response data */ +export interface ClearOperationsResponseData { + removed: number +} + +/** Request body for POST /operations */ +export interface CreateOperationBody { + type: OperationType + params: Record + description: string + command: string + dependsOn?: string +} + +/** + * Connector API endpoint contract. Both server.ts and mock-app.ts must + * conform to these shapes, enforced via `satisfies` and `AssertEndpointsImplemented`. + */ +export interface ConnectorEndpoints { + 'POST /connect': { body: { token: string }; data: ConnectResponseData } + 'GET /state': { body: never; data: StateResponseData } + 'POST /operations': { body: CreateOperationBody; data: PendingOperation } + 'POST /operations/batch': { body: CreateOperationBody[]; data: PendingOperation[] } + 'DELETE /operations': { body: never; data: void } + 'DELETE /operations/all': { body: never; data: ClearOperationsResponseData } + 'POST /approve': { body: never; data: PendingOperation } + 'POST /approve-all': { body: never; data: ApproveAllResponseData } + 'POST /retry': { body: never; data: PendingOperation } + 'POST /execute': { body: { otp?: string }; data: ExecuteResponseData } + 'GET /org/:org/users': { body: never; data: Record } + 'GET /org/:org/teams': { body: never; data: string[] } + 'GET /team/:scopeTeam/users': { body: never; data: string[] } + 'GET /package/:pkg/collaborators': { body: never; data: Record } + 'GET /user/packages': { body: never; data: Record } + 'GET /user/orgs': { body: never; data: string[] } +} + +/** Compile-time check that a server implements exactly the ConnectorEndpoints keys. */ +type IsExact = [A] extends [B] ? ([B] extends [A] ? true : false) : false +export type AssertEndpointsImplemented = + IsExact extends true + ? true + : { + error: 'Endpoint mismatch' + missing: Exclude + extra: Exclude + } diff --git a/cli/tsconfig.json b/cli/tsconfig.json index 7b2cd89b7..d878cded9 100644 --- a/cli/tsconfig.json +++ b/cli/tsconfig.json @@ -8,6 +8,7 @@ "noEmit": true, "allowImportingTsExtensions": true, "declaration": true, + "types": ["node"], "declarationMap": true }, "include": ["src/**/*.ts"], diff --git a/knip.ts b/knip.ts index e0a3199b3..257a58608 100644 --- a/knip.ts +++ b/knip.ts @@ -27,7 +27,13 @@ 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/**', + '!cli/src/**', + ], ignoreDependencies: [ '@iconify-json/*', '@voidzero-dev/vite-plus-core', @@ -44,11 +50,14 @@ 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'], }, 'cli': { - project: ['src/**/*.ts!'], + project: ['src/**/*.ts!', '!src/mock-*.ts'], }, 'docs': { entry: ['app/**/*.{ts,vue}'], diff --git a/nuxt.config.ts b/nuxt.config.ts index 5965c351e..00d89f429 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -280,6 +280,9 @@ export default defineNuxtConfig({ compilerOptions: { noUnusedLocals: true, allowImportingTsExtensions: true, + paths: { + '#cli/*': ['../cli/src/*'], + }, }, include: ['../test/unit/app/**/*.ts'], }, @@ -289,8 +292,13 @@ export default defineNuxtConfig({ nodeTsConfig: { compilerOptions: { allowImportingTsExtensions: true, + paths: { + '#cli/*': ['../cli/src/*'], + '#server/*': ['../server/*'], + '#shared/*': ['../shared/*'], + }, }, - include: ['../*.ts'], + include: ['../*.ts', '../test/e2e/**/*.ts'], }, }, diff --git a/package.json b/package.json index 12ea115e5..3b18c3a9e 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "lint:css": "node scripts/unocss-checker.ts", "generate": "nuxt generate", "npmx-connector": "pnpm --filter npmx-connector dev", + "mock-connector": "pnpm --filter npmx-connector dev:mock", "generate-pwa-icons": "pwa-assets-generator", "preview": "nuxt preview", "postinstall": "pnpm rebuild @resvg/resvg-js && pnpm generate:lexicons && pnpm generate:sprite && nuxt prepare && simple-git-hooks", @@ -127,6 +128,7 @@ "eslint-plugin-regexp": "3.0.0", "fast-check": "4.5.3", "h3": "1.15.5", + "h3-next": "npm:h3@2.0.1-rc.11", "knip": "5.83.0", "lint-staged": "16.2.7", "oxfmt": "0.27.0", diff --git a/playwright.config.ts b/playwright.config.ts index 6dc5642ea..45c8d3ebf 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -18,6 +18,8 @@ export default defineConfig({ reuseExistingServer: false, timeout: 60_000, }, + // 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)), // We currently only test on one browser on one platform snapshotPathTemplate: '{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{ext}', use: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21602824a..175172ddb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -249,6 +249,9 @@ importers: h3: specifier: 1.15.5 version: 1.15.5 + h3-next: + specifier: npm:h3@2.0.1-rc.11 + version: h3@2.0.1-rc.11 knip: specifier: 5.83.0 version: 5.83.0(@types/node@24.10.9)(typescript@5.9.3) diff --git a/test/e2e/connector.spec.ts b/test/e2e/connector.spec.ts new file mode 100644 index 000000000..84cd6035e --- /dev/null +++ b/test/e2e/connector.spec.ts @@ -0,0 +1,451 @@ +/** + * 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. + * + * 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. + */ +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, + }) => { + await mockConnector.setUserOrgs(['@testorg']) + await gotoConnected('/') + + // Header should show "packages" link for the connected user + await expectConnected(page) + }) + + test('opens connector modal and shows connected user', async ({ page, gotoConnected }) => { + await gotoConnected('/') + await expectConnected(page) + + await openConnectorModal(page) + + // The modal should show the connected user + await expect(page.getByRole('dialog')).toContainText('testuser') + }) + + test('can disconnect from the connector', async ({ page, gotoConnected }) => { + await gotoConnected('/') + await expectConnected(page) + + await openConnectorModal(page) + + const modal = page.getByRole('dialog') + + // Click disconnect button + await modal.getByRole('button', { name: /disconnect/i }).click() + + // Close the modal + await modal.getByRole('button', { name: /close/i }).click() + + // 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 }) => { + 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 }) => { + await gotoConnected('/@testorg') + + // 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 the members list + const membersList = page.getByRole('list', { name: /organization members/i }) + await expect(membersList).toBeVisible({ timeout: 10_000 }) + + // 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 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 (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(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 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 }) + + const searchInput = orgManagement.getByRole('searchbox', { name: /filter members/i }) + await searchInput.fill('member1') + + // Should only show matching member + 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 orgManagement = page.getByRole('region', { name: /organization management/i }) + await expect(orgManagement).toBeVisible({ timeout: 10_000 }) + + // Click "Add member" button + await orgManagement.getByRole('button', { name: /add member/i }).click() + + // Wait for the add-member form to appear + const usernameInput = orgManagement.locator('#new-member-username') + await expect(usernameInput).toBeVisible({ timeout: 5000 }) + + // Fill in the form + await usernameInput.fill('newuser') + + // Select role (SelectField renders id on the