[READ-ONLY] a fast, modern browser for the npm registry

test: mock connector for e2e/browser/local dev (#258)

authored by

Daniel Roe and committed by
GitHub
8722e87e 2e7a4552

+2445 -25
+103
CONTRIBUTING.md
··· 33 33 - [Available commands](#available-commands) 34 34 - [Project structure](#project-structure) 35 35 - [Local connector CLI](#local-connector-cli) 36 + - [Mock connector (for local development)](#mock-connector-for-local-development) 36 37 - [Code style](#code-style) 37 38 - [TypeScript](#typescript) 38 39 - [Server API patterns](#server-api-patterns) ··· 104 105 pnpm build # Production build 105 106 pnpm preview # Preview production build 106 107 108 + # Connector 109 + pnpm npmx-connector # Start the real connector (requires npm login) 110 + pnpm mock-connector # Start the mock connector (no npm login needed) 111 + 107 112 # Code Quality 108 113 pnpm lint # Run linter (oxlint + oxfmt) 109 114 pnpm lint:fix # Auto-fix lint issues ··· 156 161 ``` 157 162 158 163 The connector will check your npm authentication, generate a connection token, and listen for requests from npmx.dev. 164 + 165 + ### Mock connector (for local development) 166 + 167 + 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: 168 + 169 + ```bash 170 + pnpm mock-connector 171 + ``` 172 + 173 + 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. 174 + 175 + 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. 176 + 177 + **Options:** 178 + 179 + ```bash 180 + pnpm mock-connector # default: port 31415, user "mock-user", sample data 181 + pnpm mock-connector --port 9999 # custom port 182 + pnpm mock-connector --user alice # custom username 183 + pnpm mock-connector --empty # start with no pre-populated data 184 + ``` 185 + 186 + **Default sample data:** 187 + 188 + - **@nuxt**: 4 members (mock-user, danielroe, pi0, antfu), 3 teams (core, docs, triage) 189 + - **@unjs**: 2 members (mock-user, pi0), 1 team (maintainers) 190 + - **Packages**: @nuxt/kit, @nuxt/schema, @unjs/nitro with team-based access controls 191 + 192 + > [!TIP] 193 + > Run `pnpm dev` in a separate terminal to start the Nuxt dev server, then click the connection URL from the mock connector to connect. 159 194 160 195 ## Code style 161 196 ··· 751 786 752 787 1. Add a fixture file for that package/endpoint 753 788 2. Update the mock handlers in `test/fixtures/mock-routes.cjs` (client) or `modules/runtime/server/cache.ts` (server) 789 + 790 + ### Testing connector features 791 + 792 + Features that require authentication through the local connector (org management, package collaborators, operations queue) are tested using a mock connector server. 793 + 794 + #### Architecture 795 + 796 + The mock connector infrastructure is shared between the CLI, E2E tests, and Vitest component tests: 797 + 798 + ``` 799 + cli/src/ 800 + ├── types.ts # ConnectorEndpoints contract (shared by real + mock) 801 + ├── mock-state.ts # MockConnectorStateManager (canonical source) 802 + ├── mock-app.ts # H3 mock app + MockConnectorServer class 803 + └── mock-server.ts # CLI entry point (pnpm mock-connector) 804 + 805 + test/test-utils/ # Re-exports from cli/src/ for test convenience 806 + test/e2e/helpers/ # E2E-specific wrappers (fixtures, global setup) 807 + ``` 808 + 809 + 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. 810 + 811 + #### Vitest component tests (`test/nuxt/`) 812 + 813 + - Mock the `useConnector` composable with reactive state 814 + - Use `document.body` queries for components using Teleport 815 + - See `test/nuxt/components/HeaderConnectorModal.spec.ts` for an example 816 + 817 + ```typescript 818 + // Create mock state 819 + const mockState = ref({ connected: false, npmUser: null, ... }) 820 + 821 + // Mock the composable 822 + vi.mock('~/composables/useConnector', () => ({ 823 + useConnector: () => ({ 824 + isConnected: computed(() => mockState.value.connected), 825 + // ... other properties 826 + }), 827 + })) 828 + ``` 829 + 830 + #### Playwright E2E tests (`test/e2e/`) 831 + 832 + - A mock HTTP server starts automatically via Playwright's global setup 833 + - Use the `mockConnector` fixture to set up test data and the `gotoConnected` helper to navigate with authentication 834 + 835 + ```typescript 836 + test('shows org members', async ({ page, gotoConnected, mockConnector }) => { 837 + // Set up test data 838 + await mockConnector.setOrgData('@testorg', { 839 + users: { testuser: 'owner', member1: 'admin' }, 840 + }) 841 + 842 + // Navigate with connector authentication 843 + await gotoConnected('/@testorg') 844 + 845 + // Test assertions 846 + await expect(page.getByRole('link', { name: '@testuser' })).toBeVisible() 847 + }) 848 + ``` 849 + 850 + The mock connector supports test endpoints for state manipulation: 851 + 852 + - `/__test__/reset` - Reset all mock state 853 + - `/__test__/org` - Set org users, teams, and team members 854 + - `/__test__/user-orgs` - Set user's organizations 855 + - `/__test__/user-packages` - Set user's packages 856 + - `/__test__/package` - Set package collaborators 754 857 755 858 ## Submitting changes 756 859
+1 -1
app/composables/useConnector.ts
··· 1 - import type { PendingOperation, OperationStatus, OperationType } from '../../cli/src/types' 1 + import type { PendingOperation, OperationStatus, OperationType } from '#cli/types' 2 2 import { $fetch } from 'ofetch' 3 3 4 4 export interface NewOperation {
+2 -4
cli/package.json
··· 17 17 "npmx-connector": "./dist/cli.mjs" 18 18 }, 19 19 "exports": { 20 - ".": { 21 - "import": "./dist/index.mjs", 22 - "types": "./dist/index.d.mts" 23 - } 20 + ".": "./dist/index.mjs" 24 21 }, 25 22 "files": [ 26 23 "dist" ··· 29 26 "build": "tsdown", 30 27 "dev": "NPMX_CLI_DEV=true node src/cli.ts", 31 28 "dev:debug": "DEBUG=npmx-connector NPMX_CLI_DEV=true node src/cli.ts", 29 + "dev:mock": "NPMX_CLI_DEV=true node src/mock-server.ts", 32 30 "test:types": "tsc --noEmit" 33 31 }, 34 32 "dependencies": {
+467
cli/src/mock-app.ts
··· 1 + /** 2 + * Mock connector H3 application. Same API as the real server (server.ts) 3 + * but backed by in-memory state. Used by the mock CLI and E2E tests. 4 + */ 5 + 6 + import { H3, HTTPError, handleCors, type H3Event } from 'h3-next' 7 + import type { CorsOptions } from 'h3-next' 8 + import { serve, type Server } from 'srvx' 9 + import type { 10 + OperationType, 11 + ApiResponse, 12 + ConnectorEndpoints, 13 + AssertEndpointsImplemented, 14 + } from './types.ts' 15 + import type { MockConnectorStateManager } from './mock-state.ts' 16 + 17 + // Endpoint completeness check — errors if this list diverges from ConnectorEndpoints. 18 + // oxlint-disable-next-line no-unused-vars 19 + const _endpointCheck: AssertEndpointsImplemented< 20 + | 'POST /connect' 21 + | 'GET /state' 22 + | 'POST /operations' 23 + | 'POST /operations/batch' 24 + | 'DELETE /operations' 25 + | 'DELETE /operations/all' 26 + | 'POST /approve' 27 + | 'POST /approve-all' 28 + | 'POST /retry' 29 + | 'POST /execute' 30 + | 'GET /org/:org/users' 31 + | 'GET /org/:org/teams' 32 + | 'GET /team/:scopeTeam/users' 33 + | 'GET /package/:pkg/collaborators' 34 + | 'GET /user/packages' 35 + | 'GET /user/orgs' 36 + > = true 37 + void _endpointCheck 38 + 39 + const corsOptions: CorsOptions = { 40 + origin: '*', 41 + methods: ['GET', 'POST', 'DELETE', 'OPTIONS'], 42 + allowHeaders: ['Content-Type', 'Authorization'], 43 + } 44 + 45 + function createMockConnectorApp(stateManager: MockConnectorStateManager) { 46 + const app = new H3() 47 + 48 + app.use((event: H3Event) => { 49 + const corsResult = handleCors(event, corsOptions) 50 + if (corsResult !== false) { 51 + return corsResult 52 + } 53 + }) 54 + 55 + function requireAuth(event: H3Event): void { 56 + const authHeader = event.req.headers.get('authorization') 57 + if (!authHeader || !authHeader.startsWith('Bearer ')) { 58 + throw new HTTPError({ statusCode: 401, message: 'Authorization required' }) 59 + } 60 + const token = authHeader.slice(7) 61 + if (token !== stateManager.token) { 62 + throw new HTTPError({ statusCode: 401, message: 'Invalid token' }) 63 + } 64 + if (!stateManager.isConnected()) { 65 + throw new HTTPError({ statusCode: 401, message: 'Not connected' }) 66 + } 67 + } 68 + 69 + // POST /connect 70 + app.post('/connect', async (event: H3Event) => { 71 + const body = (await event.req.json()) as { token?: string } 72 + const token = body?.token 73 + 74 + if (!token || token !== stateManager.token) { 75 + throw new HTTPError({ statusCode: 401, message: 'Invalid token' }) 76 + } 77 + 78 + stateManager.connect(token) 79 + 80 + return { 81 + success: true, 82 + data: { 83 + npmUser: stateManager.config.npmUser, 84 + avatar: stateManager.config.avatar ?? null, 85 + connectedAt: stateManager.state.connectedAt ?? Date.now(), 86 + }, 87 + } satisfies ApiResponse<ConnectorEndpoints['POST /connect']['data']> 88 + }) 89 + 90 + // GET /state 91 + app.get('/state', (event: H3Event) => { 92 + requireAuth(event) 93 + 94 + return { 95 + success: true, 96 + data: { 97 + npmUser: stateManager.config.npmUser, 98 + avatar: stateManager.config.avatar ?? null, 99 + operations: stateManager.getOperations(), 100 + }, 101 + } satisfies ApiResponse<ConnectorEndpoints['GET /state']['data']> 102 + }) 103 + 104 + // POST /operations 105 + app.post('/operations', async (event: H3Event) => { 106 + requireAuth(event) 107 + 108 + const body = (await event.req.json()) as { 109 + type?: string 110 + params?: Record<string, string> 111 + description?: string 112 + command?: string 113 + dependsOn?: string 114 + } 115 + if (!body?.type || !body.description || !body.command) { 116 + throw new HTTPError({ statusCode: 400, message: 'Missing required fields' }) 117 + } 118 + 119 + const operation = stateManager.addOperation({ 120 + type: body.type as OperationType, 121 + params: body.params ?? {}, 122 + description: body.description, 123 + command: body.command, 124 + dependsOn: body.dependsOn, 125 + }) 126 + 127 + return { 128 + success: true, 129 + data: operation, 130 + } satisfies ApiResponse<ConnectorEndpoints['POST /operations']['data']> 131 + }) 132 + 133 + // POST /operations/batch 134 + app.post('/operations/batch', async (event: H3Event) => { 135 + requireAuth(event) 136 + 137 + const body = await event.req.json() 138 + if (!Array.isArray(body)) { 139 + throw new HTTPError({ statusCode: 400, message: 'Expected array of operations' }) 140 + } 141 + 142 + const operations = stateManager.addOperations(body) 143 + return { 144 + success: true, 145 + data: operations, 146 + } satisfies ApiResponse<ConnectorEndpoints['POST /operations/batch']['data']> 147 + }) 148 + 149 + // DELETE /operations?id=<id> 150 + app.delete('/operations', (event: H3Event) => { 151 + requireAuth(event) 152 + 153 + const id = new URL(event.req.url).searchParams.get('id') 154 + if (!id) { 155 + throw new HTTPError({ statusCode: 400, message: 'Missing operation id' }) 156 + } 157 + 158 + const removed = stateManager.removeOperation(id) 159 + if (!removed) { 160 + throw new HTTPError({ statusCode: 404, message: 'Operation not found or cannot be removed' }) 161 + } 162 + 163 + return { success: true } satisfies ApiResponse<ConnectorEndpoints['DELETE /operations']['data']> 164 + }) 165 + 166 + // DELETE /operations/all 167 + app.delete('/operations/all', (event: H3Event) => { 168 + requireAuth(event) 169 + 170 + const removed = stateManager.clearOperations() 171 + return { 172 + success: true, 173 + data: { removed }, 174 + } satisfies ApiResponse<ConnectorEndpoints['DELETE /operations/all']['data']> 175 + }) 176 + 177 + // POST /approve?id=<id> 178 + app.post('/approve', (event: H3Event) => { 179 + requireAuth(event) 180 + 181 + const id = new URL(event.req.url).searchParams.get('id') 182 + if (!id) { 183 + throw new HTTPError({ statusCode: 400, message: 'Missing operation id' }) 184 + } 185 + 186 + const operation = stateManager.approveOperation(id) 187 + if (!operation) { 188 + throw new HTTPError({ statusCode: 404, message: 'Operation not found or not pending' }) 189 + } 190 + 191 + return { 192 + success: true, 193 + data: operation, 194 + } satisfies ApiResponse<ConnectorEndpoints['POST /approve']['data']> 195 + }) 196 + 197 + // POST /approve-all 198 + app.post('/approve-all', (event: H3Event) => { 199 + requireAuth(event) 200 + 201 + const approved = stateManager.approveAll() 202 + return { 203 + success: true, 204 + data: { approved }, 205 + } satisfies ApiResponse<ConnectorEndpoints['POST /approve-all']['data']> 206 + }) 207 + 208 + // POST /retry?id=<id> 209 + app.post('/retry', (event: H3Event) => { 210 + requireAuth(event) 211 + 212 + const id = new URL(event.req.url).searchParams.get('id') 213 + if (!id) { 214 + throw new HTTPError({ statusCode: 400, message: 'Missing operation id' }) 215 + } 216 + 217 + const operation = stateManager.retryOperation(id) 218 + if (!operation) { 219 + throw new HTTPError({ statusCode: 404, message: 'Operation not found or not failed' }) 220 + } 221 + 222 + return { 223 + success: true, 224 + data: operation, 225 + } satisfies ApiResponse<ConnectorEndpoints['POST /retry']['data']> 226 + }) 227 + 228 + // POST /execute 229 + app.post('/execute', async (event: H3Event) => { 230 + requireAuth(event) 231 + 232 + const body = await event.req.json().catch(() => ({})) 233 + const otp = (body as { otp?: string })?.otp 234 + 235 + const { results, otpRequired } = stateManager.executeOperations({ otp }) 236 + 237 + return { 238 + success: true, 239 + data: { results, otpRequired }, 240 + } satisfies ApiResponse<ConnectorEndpoints['POST /execute']['data']> 241 + }) 242 + 243 + // GET /org/:org/users 244 + app.get('/org/:org/users', (event: H3Event) => { 245 + requireAuth(event) 246 + 247 + const org = event.context.params?.org 248 + if (!org) { 249 + throw new HTTPError({ statusCode: 400, message: 'Missing org parameter' }) 250 + } 251 + 252 + const normalizedOrg = org.startsWith('@') ? org : `@${org}` 253 + const users = stateManager.getOrgUsers(normalizedOrg) 254 + if (users === null) { 255 + return { success: true, data: {} } satisfies ApiResponse< 256 + ConnectorEndpoints['GET /org/:org/users']['data'] 257 + > 258 + } 259 + 260 + return { success: true, data: users } satisfies ApiResponse< 261 + ConnectorEndpoints['GET /org/:org/users']['data'] 262 + > 263 + }) 264 + 265 + // GET /org/:org/teams 266 + app.get('/org/:org/teams', (event: H3Event) => { 267 + requireAuth(event) 268 + 269 + const org = event.context.params?.org 270 + if (!org) { 271 + throw new HTTPError({ statusCode: 400, message: 'Missing org parameter' }) 272 + } 273 + 274 + const normalizedOrg = org.startsWith('@') ? org : `@${org}` 275 + const orgName = normalizedOrg.slice(1) 276 + 277 + const teams = stateManager.getOrgTeams(normalizedOrg) 278 + const formattedTeams = teams ? teams.map(t => `${orgName}:${t}`) : [] 279 + return { success: true, data: formattedTeams } satisfies ApiResponse< 280 + ConnectorEndpoints['GET /org/:org/teams']['data'] 281 + > 282 + }) 283 + 284 + // GET /team/:scopeTeam/users 285 + app.get('/team/:scopeTeam/users', (event: H3Event) => { 286 + requireAuth(event) 287 + 288 + const scopeTeam = event.context.params?.scopeTeam 289 + if (!scopeTeam) { 290 + throw new HTTPError({ statusCode: 400, message: 'Missing scopeTeam parameter' }) 291 + } 292 + 293 + if (!scopeTeam.startsWith('@') || !scopeTeam.includes(':')) { 294 + throw new HTTPError({ 295 + statusCode: 400, 296 + message: 'Invalid scope:team format (expected @scope:team)', 297 + }) 298 + } 299 + 300 + const [scope, team] = scopeTeam.split(':') 301 + if (!scope || !team) { 302 + throw new HTTPError({ statusCode: 400, message: 'Invalid scope:team format' }) 303 + } 304 + 305 + const users = stateManager.getTeamUsers(scope, team) 306 + return { success: true, data: users ?? [] } satisfies ApiResponse< 307 + ConnectorEndpoints['GET /team/:scopeTeam/users']['data'] 308 + > 309 + }) 310 + 311 + // GET /package/:pkg/collaborators 312 + app.get('/package/:pkg/collaborators', (event: H3Event) => { 313 + requireAuth(event) 314 + 315 + const pkg = event.context.params?.pkg 316 + if (!pkg) { 317 + throw new HTTPError({ statusCode: 400, message: 'Missing package parameter' }) 318 + } 319 + 320 + const collaborators = stateManager.getPackageCollaborators(decodeURIComponent(pkg)) 321 + return { success: true, data: collaborators ?? {} } satisfies ApiResponse< 322 + ConnectorEndpoints['GET /package/:pkg/collaborators']['data'] 323 + > 324 + }) 325 + 326 + // GET /user/packages 327 + app.get('/user/packages', (event: H3Event) => { 328 + requireAuth(event) 329 + 330 + const packages = stateManager.getUserPackages() 331 + return { success: true, data: packages } satisfies ApiResponse< 332 + ConnectorEndpoints['GET /user/packages']['data'] 333 + > 334 + }) 335 + 336 + // GET /user/orgs 337 + app.get('/user/orgs', (event: H3Event) => { 338 + requireAuth(event) 339 + 340 + const orgs = stateManager.getUserOrgs() 341 + return { success: true, data: orgs } satisfies ApiResponse< 342 + ConnectorEndpoints['GET /user/orgs']['data'] 343 + > 344 + }) 345 + 346 + // -- Test-only endpoints -- 347 + 348 + // POST /__test__/reset 349 + app.post('/__test__/reset', () => { 350 + stateManager.reset() 351 + return { success: true } 352 + }) 353 + 354 + // POST /__test__/org 355 + app.post('/__test__/org', async (event: H3Event) => { 356 + const body = (await event.req.json()) as { 357 + org?: string 358 + users?: Record<string, 'developer' | 'admin' | 'owner'> 359 + teams?: string[] 360 + teamMembers?: Record<string, string[]> 361 + } 362 + if (!body?.org) { 363 + throw new HTTPError({ statusCode: 400, message: 'Missing org parameter' }) 364 + } 365 + 366 + stateManager.setOrgData(body.org, { 367 + users: body.users, 368 + teams: body.teams, 369 + teamMembers: body.teamMembers, 370 + }) 371 + 372 + return { success: true } 373 + }) 374 + 375 + // POST /__test__/user-orgs 376 + app.post('/__test__/user-orgs', async (event: H3Event) => { 377 + const body = (await event.req.json()) as { orgs?: string[] } 378 + if (!body?.orgs) { 379 + throw new HTTPError({ statusCode: 400, message: 'Missing orgs parameter' }) 380 + } 381 + 382 + stateManager.setUserOrgs(body.orgs) 383 + return { success: true } 384 + }) 385 + 386 + // POST /__test__/user-packages 387 + app.post('/__test__/user-packages', async (event: H3Event) => { 388 + const body = (await event.req.json()) as { 389 + packages?: Record<string, 'read-only' | 'read-write'> 390 + } 391 + if (!body?.packages) { 392 + throw new HTTPError({ statusCode: 400, message: 'Missing packages parameter' }) 393 + } 394 + 395 + stateManager.setUserPackages(body.packages) 396 + return { success: true } 397 + }) 398 + 399 + // POST /__test__/package 400 + app.post('/__test__/package', async (event: H3Event) => { 401 + const body = (await event.req.json()) as { 402 + package?: string 403 + collaborators?: Record<string, 'read-only' | 'read-write'> 404 + } 405 + if (!body?.package) { 406 + throw new HTTPError({ statusCode: 400, message: 'Missing package parameter' }) 407 + } 408 + 409 + stateManager.setPackageData(body.package, { 410 + collaborators: body.collaborators ?? {}, 411 + }) 412 + 413 + return { success: true } 414 + }) 415 + 416 + return app 417 + } 418 + 419 + /** Wraps the mock H3 app in an HTTP server via srvx. */ 420 + export class MockConnectorServer { 421 + private server: Server | null = null 422 + private stateManager: MockConnectorStateManager 423 + 424 + constructor(stateManager: MockConnectorStateManager) { 425 + this.stateManager = stateManager 426 + } 427 + 428 + async start(): Promise<void> { 429 + if (this.server) { 430 + throw new Error('Mock connector server is already running') 431 + } 432 + 433 + const app = createMockConnectorApp(this.stateManager) 434 + 435 + this.server = serve({ 436 + port: this.stateManager.port, 437 + hostname: '127.0.0.1', 438 + fetch: app.fetch, 439 + }) 440 + 441 + await this.server.ready() 442 + console.log(`[Mock Connector] Started on http://127.0.0.1:${this.stateManager.port}`) 443 + } 444 + 445 + async stop(): Promise<void> { 446 + if (!this.server) return 447 + await this.server.close() 448 + console.log('[Mock Connector] Stopped') 449 + this.server = null 450 + } 451 + 452 + get state(): MockConnectorStateManager { 453 + return this.stateManager 454 + } 455 + 456 + get port(): number { 457 + return this.stateManager.port 458 + } 459 + 460 + get token(): string { 461 + return this.stateManager.token 462 + } 463 + 464 + reset(): void { 465 + this.stateManager.reset() 466 + } 467 + }
+168
cli/src/mock-server.ts
··· 1 + #!/usr/bin/env node 2 + /** 3 + * Mock connector CLI — starts a pre-populated mock server for developing 4 + * authenticated features without a real npm account. 5 + */ 6 + 7 + import process from 'node:process' 8 + import crypto from 'node:crypto' 9 + import { styleText } from 'node:util' 10 + import * as p from '@clack/prompts' 11 + import { defineCommand, runMain } from 'citty' 12 + import { 13 + MockConnectorStateManager, 14 + createMockConnectorState, 15 + type MockConnectorConfig, 16 + } from './mock-state.ts' 17 + import { MockConnectorServer } from './mock-app.ts' 18 + 19 + const DEFAULT_PORT = 31415 20 + const DEV_FRONTEND_URL = 'http://127.0.0.1:3000/' 21 + const PROD_FRONTEND_URL = 'https://npmx.dev/' 22 + 23 + function generateToken(): string { 24 + return crypto.randomBytes(16).toString('hex') 25 + } 26 + 27 + /** 28 + * Pre-populate with sample data using real npm orgs so the registry 29 + * API calls don't 404. Members/teams are fictional. 30 + */ 31 + function populateDefaultData(stateManager: MockConnectorStateManager): void { 32 + const npmUser = stateManager.config.npmUser 33 + 34 + stateManager.setOrgData('@nuxt', { 35 + users: { 36 + [npmUser]: 'owner', 37 + danielroe: 'owner', 38 + pi0: 'admin', 39 + antfu: 'developer', 40 + }, 41 + teams: ['core', 'docs', 'triage'], 42 + teamMembers: { 43 + core: [npmUser, 'danielroe', 'pi0'], 44 + docs: ['antfu'], 45 + triage: ['pi0', 'antfu'], 46 + }, 47 + }) 48 + 49 + stateManager.setOrgData('@unjs', { 50 + users: { 51 + [npmUser]: 'admin', 52 + pi0: 'owner', 53 + }, 54 + teams: ['maintainers'], 55 + teamMembers: { 56 + maintainers: [npmUser, 'pi0'], 57 + }, 58 + }) 59 + 60 + stateManager.setUserOrgs(['nuxt', 'unjs']) 61 + 62 + stateManager.setPackageData('@nuxt/kit', { 63 + collaborators: { 64 + [npmUser]: 'read-write', 65 + 'danielroe': 'read-write', 66 + 'nuxt:core': 'read-write', 67 + 'nuxt:docs': 'read-only', 68 + }, 69 + }) 70 + stateManager.setPackageData('@nuxt/schema', { 71 + collaborators: { 72 + [npmUser]: 'read-write', 73 + 'nuxt:core': 'read-write', 74 + }, 75 + }) 76 + stateManager.setPackageData('@unjs/nitro', { 77 + collaborators: { 78 + [npmUser]: 'read-write', 79 + 'pi0': 'read-write', 80 + 'unjs:maintainers': 'read-write', 81 + }, 82 + }) 83 + 84 + stateManager.setUserPackages({ 85 + '@nuxt/kit': 'read-write', 86 + '@nuxt/schema': 'read-write', 87 + '@unjs/nitro': 'read-write', 88 + }) 89 + } 90 + 91 + const main = defineCommand({ 92 + meta: { 93 + name: 'npmx-connector-mock', 94 + version: '0.0.1', 95 + description: 'Mock connector for npmx.dev development and testing', 96 + }, 97 + args: { 98 + port: { 99 + type: 'string', 100 + description: 'Port to listen on', 101 + default: String(DEFAULT_PORT), 102 + }, 103 + user: { 104 + type: 'string', 105 + description: 'Simulated npm username', 106 + default: 'mock-user', 107 + }, 108 + empty: { 109 + type: 'boolean', 110 + description: 'Start with empty state (no pre-populated data)', 111 + default: false, 112 + }, 113 + }, 114 + async run({ args }) { 115 + const port = Number.parseInt(args.port as string, 10) || DEFAULT_PORT 116 + const npmUser = args.user as string 117 + const empty = args.empty as boolean 118 + const frontendUrl = process.env.NPMX_CLI_DEV === 'true' ? DEV_FRONTEND_URL : PROD_FRONTEND_URL 119 + 120 + p.intro(styleText(['bgMagenta', 'white'], ' npmx mock connector ')) 121 + 122 + const token = generateToken() 123 + const config: MockConnectorConfig = { 124 + token, 125 + npmUser, 126 + avatar: null, 127 + port, 128 + } 129 + const stateManager = new MockConnectorStateManager(createMockConnectorState(config)) 130 + 131 + if (!empty) { 132 + populateDefaultData(stateManager) 133 + p.log.info(`Pre-populated with sample data for ${styleText('cyan', npmUser)}`) 134 + p.log.info(styleText('dim', ` Orgs: @nuxt (4 members, 3 teams), @unjs (2 members, 1 team)`)) 135 + p.log.info(styleText('dim', ` Packages: @nuxt/kit, @nuxt/schema, @unjs/nitro`)) 136 + } else { 137 + p.log.info('Starting with empty state') 138 + } 139 + 140 + stateManager.connect(token) 141 + const server = new MockConnectorServer(stateManager) 142 + 143 + try { 144 + await server.start() 145 + } catch (error) { 146 + p.log.error(error instanceof Error ? error.message : 'Failed to start mock connector server') 147 + process.exit(1) 148 + } 149 + 150 + const connectUrl = `${frontendUrl}?token=${token}&port=${port}` 151 + 152 + p.note( 153 + [ 154 + `Open: ${styleText(['bold', 'underline', 'cyan'], connectUrl)}`, 155 + '', 156 + styleText('dim', `Or paste token manually: ${token}`), 157 + '', 158 + styleText('dim', `User: ${npmUser} | Port: ${port}`), 159 + styleText('dim', 'Operations will succeed immediately (no real npm calls)'), 160 + ].join('\n'), 161 + 'Click to connect', 162 + ) 163 + 164 + p.log.info('Waiting for connection... (Press Ctrl+C to stop)') 165 + }, 166 + }) 167 + 168 + runMain(main)
+526
cli/src/mock-state.ts
··· 1 + /** 2 + * Mock connector state management. Canonical source used by the mock server, 3 + * E2E tests, and Vitest composable mocks. 4 + */ 5 + 6 + import type { 7 + PendingOperation, 8 + OperationType, 9 + OperationResult, 10 + OrgRole, 11 + AccessPermission, 12 + } from './types.ts' 13 + 14 + export interface MockConnectorConfig { 15 + token: string 16 + npmUser: string 17 + avatar?: string | null 18 + port?: number 19 + } 20 + 21 + export interface MockOrgData { 22 + users: Record<string, OrgRole> 23 + teams: string[] 24 + /** team name -> member usernames */ 25 + teamMembers: Record<string, string[]> 26 + } 27 + 28 + export interface MockPackageData { 29 + collaborators: Record<string, AccessPermission> 30 + } 31 + 32 + export interface MockConnectorStateData { 33 + config: MockConnectorConfig 34 + connected: boolean 35 + connectedAt: number | null 36 + orgs: Record<string, MockOrgData> 37 + packages: Record<string, MockPackageData> 38 + userPackages: Record<string, AccessPermission> 39 + userOrgs: string[] 40 + operations: PendingOperation[] 41 + operationIdCounter: number 42 + } 43 + 44 + export interface NewOperationInput { 45 + type: OperationType 46 + params: Record<string, string> 47 + description: string 48 + command: string 49 + dependsOn?: string 50 + } 51 + 52 + export interface ExecuteOptions { 53 + otp?: string 54 + /** Per-operation results for testing failures. */ 55 + results?: Record<string, Partial<OperationResult>> 56 + } 57 + 58 + export interface ExecuteResult { 59 + results: Array<{ id: string; result: OperationResult }> 60 + otpRequired?: boolean 61 + } 62 + 63 + export function createMockConnectorState(config: MockConnectorConfig): MockConnectorStateData { 64 + return { 65 + config: { 66 + port: 31415, 67 + avatar: null, 68 + ...config, 69 + }, 70 + connected: false, 71 + connectedAt: null, 72 + orgs: {}, 73 + packages: {}, 74 + userPackages: {}, 75 + userOrgs: [], 76 + operations: [], 77 + operationIdCounter: 0, 78 + } 79 + } 80 + 81 + /** 82 + * Mock connector state, shared between the HTTP server and composable mock. 83 + */ 84 + export class MockConnectorStateManager { 85 + public state: MockConnectorStateData 86 + 87 + constructor(initialState: MockConnectorStateData) { 88 + this.state = initialState 89 + } 90 + 91 + // -- Configuration -- 92 + 93 + get config(): MockConnectorConfig { 94 + return this.state.config 95 + } 96 + 97 + get token(): string { 98 + return this.state.config.token 99 + } 100 + 101 + get port(): number { 102 + return this.state.config.port ?? 31415 103 + } 104 + 105 + // -- Connection -- 106 + 107 + connect(token: string): boolean { 108 + if (token !== this.state.config.token) { 109 + return false 110 + } 111 + this.state.connected = true 112 + this.state.connectedAt = Date.now() 113 + return true 114 + } 115 + 116 + disconnect(): void { 117 + this.state.connected = false 118 + this.state.connectedAt = null 119 + this.state.operations = [] 120 + } 121 + 122 + isConnected(): boolean { 123 + return this.state.connected 124 + } 125 + 126 + // -- Org data -- 127 + 128 + setOrgData(org: string, data: Partial<MockOrgData>): void { 129 + const existing = this.state.orgs[org] ?? { users: {}, teams: [], teamMembers: {} } 130 + this.state.orgs[org] = { 131 + users: { ...existing.users, ...data.users }, 132 + teams: data.teams ?? existing.teams, 133 + teamMembers: { ...existing.teamMembers, ...data.teamMembers }, 134 + } 135 + } 136 + 137 + getOrgUsers(org: string): Record<string, OrgRole> | null { 138 + const normalizedOrg = org.startsWith('@') ? org : `@${org}` 139 + return this.state.orgs[normalizedOrg]?.users ?? null 140 + } 141 + 142 + getOrgTeams(org: string): string[] | null { 143 + const normalizedOrg = org.startsWith('@') ? org : `@${org}` 144 + return this.state.orgs[normalizedOrg]?.teams ?? null 145 + } 146 + 147 + getTeamUsers(scope: string, team: string): string[] | null { 148 + const normalizedScope = scope.startsWith('@') ? scope : `@${scope}` 149 + const org = this.state.orgs[normalizedScope] 150 + if (!org) return null 151 + return org.teamMembers[team] ?? null 152 + } 153 + 154 + // -- Package data -- 155 + 156 + setPackageData(pkg: string, data: MockPackageData): void { 157 + this.state.packages[pkg] = data 158 + } 159 + 160 + getPackageCollaborators(pkg: string): Record<string, AccessPermission> | null { 161 + return this.state.packages[pkg]?.collaborators ?? null 162 + } 163 + 164 + // -- User data -- 165 + 166 + setUserPackages(packages: Record<string, AccessPermission>): void { 167 + this.state.userPackages = packages 168 + } 169 + 170 + setUserOrgs(orgs: string[]): void { 171 + this.state.userOrgs = orgs 172 + } 173 + 174 + getUserPackages(): Record<string, AccessPermission> { 175 + return this.state.userPackages 176 + } 177 + 178 + getUserOrgs(): string[] { 179 + return this.state.userOrgs 180 + } 181 + 182 + // -- Operations queue -- 183 + 184 + addOperation(operation: NewOperationInput): PendingOperation { 185 + const id = `op-${++this.state.operationIdCounter}` 186 + const newOp: PendingOperation = { 187 + id, 188 + type: operation.type, 189 + params: operation.params, 190 + description: operation.description, 191 + command: operation.command, 192 + status: 'pending', 193 + createdAt: Date.now(), 194 + dependsOn: operation.dependsOn, 195 + } 196 + this.state.operations.push(newOp) 197 + return newOp 198 + } 199 + 200 + addOperations(operations: NewOperationInput[]): PendingOperation[] { 201 + return operations.map(op => this.addOperation(op)) 202 + } 203 + 204 + getOperation(id: string): PendingOperation | undefined { 205 + return this.state.operations.find(op => op.id === id) 206 + } 207 + 208 + getOperations(): PendingOperation[] { 209 + return this.state.operations 210 + } 211 + 212 + removeOperation(id: string): boolean { 213 + const index = this.state.operations.findIndex(op => op.id === id) 214 + if (index === -1) return false 215 + const op = this.state.operations[index] 216 + // Can't remove running operations 217 + if (op?.status === 'running') return false 218 + this.state.operations.splice(index, 1) 219 + return true 220 + } 221 + 222 + clearOperations(): number { 223 + const removable = this.state.operations.filter(op => op.status !== 'running') 224 + const count = removable.length 225 + this.state.operations = this.state.operations.filter(op => op.status === 'running') 226 + return count 227 + } 228 + 229 + approveOperation(id: string): PendingOperation | null { 230 + const op = this.state.operations.find(op => op.id === id) 231 + if (!op || op.status !== 'pending') return null 232 + op.status = 'approved' 233 + return op 234 + } 235 + 236 + approveAll(): number { 237 + let count = 0 238 + for (const op of this.state.operations) { 239 + if (op.status === 'pending') { 240 + op.status = 'approved' 241 + count++ 242 + } 243 + } 244 + return count 245 + } 246 + 247 + retryOperation(id: string): PendingOperation | null { 248 + const op = this.state.operations.find(op => op.id === id) 249 + if (!op || op.status !== 'failed') return null 250 + op.status = 'approved' 251 + op.result = undefined 252 + return op 253 + } 254 + 255 + /** Execute all approved operations (mock: instant success unless configured otherwise). */ 256 + executeOperations(options?: ExecuteOptions): ExecuteResult { 257 + const results: Array<{ id: string; result: OperationResult }> = [] 258 + const approved = this.state.operations.filter(op => op.status === 'approved') 259 + 260 + // Sort by dependencies 261 + const sorted = this.sortByDependencies(approved) 262 + 263 + for (const op of sorted) { 264 + // Check if dependent operation completed successfully 265 + if (op.dependsOn) { 266 + const dep = this.state.operations.find(d => d.id === op.dependsOn) 267 + if (!dep || dep.status !== 'completed') { 268 + // Skip - dependency not met 269 + continue 270 + } 271 + } 272 + 273 + op.status = 'running' 274 + 275 + // Check for configured result 276 + const configuredResult = options?.results?.[op.id] 277 + if (configuredResult) { 278 + const result: OperationResult = { 279 + stdout: configuredResult.stdout ?? '', 280 + stderr: configuredResult.stderr ?? '', 281 + exitCode: configuredResult.exitCode ?? 1, 282 + requiresOtp: configuredResult.requiresOtp, 283 + authFailure: configuredResult.authFailure, 284 + } 285 + op.result = result 286 + op.status = result.exitCode === 0 ? 'completed' : 'failed' 287 + results.push({ id: op.id, result }) 288 + 289 + if (result.requiresOtp && !options?.otp) { 290 + return { results, otpRequired: true } 291 + } 292 + } else { 293 + // Default: success 294 + const result: OperationResult = { 295 + stdout: `Mock: ${op.command}`, 296 + stderr: '', 297 + exitCode: 0, 298 + } 299 + op.result = result 300 + op.status = 'completed' 301 + results.push({ id: op.id, result }) 302 + 303 + // Apply the operation's effects to mock state 304 + this.applyOperationEffect(op) 305 + } 306 + } 307 + 308 + return { results } 309 + } 310 + 311 + /** Apply side effects of a completed operation. Param keys match schemas.ts. */ 312 + private applyOperationEffect(op: PendingOperation): void { 313 + const { type, params } = op 314 + 315 + switch (type) { 316 + case 'org:add-user': { 317 + // Params: { org, user, role } — OrgAddUserParamsSchema 318 + const org = params['org'] 319 + const user = params['user'] 320 + const role = (params['role'] as OrgRole) ?? 'developer' 321 + if (org && user) { 322 + const normalizedOrg = org.startsWith('@') ? org : `@${org}` 323 + if (!this.state.orgs[normalizedOrg]) { 324 + this.state.orgs[normalizedOrg] = { users: {}, teams: [], teamMembers: {} } 325 + } 326 + this.state.orgs[normalizedOrg].users[user] = role 327 + } 328 + break 329 + } 330 + case 'org:rm-user': { 331 + // Params: { org, user } — OrgRemoveUserParamsSchema 332 + const org = params['org'] 333 + const user = params['user'] 334 + if (org && user) { 335 + const normalizedOrg = org.startsWith('@') ? org : `@${org}` 336 + if (this.state.orgs[normalizedOrg]) { 337 + delete this.state.orgs[normalizedOrg].users[user] 338 + } 339 + } 340 + break 341 + } 342 + case 'org:set-role': { 343 + // Params: { org, user, role } — reuses OrgAddUserParamsSchema 344 + const org = params['org'] 345 + const user = params['user'] 346 + const role = params['role'] as OrgRole 347 + if (org && user && role) { 348 + const normalizedOrg = org.startsWith('@') ? org : `@${org}` 349 + if (this.state.orgs[normalizedOrg]) { 350 + this.state.orgs[normalizedOrg].users[user] = role 351 + } 352 + } 353 + break 354 + } 355 + case 'team:create': { 356 + // Params: { scopeTeam } — TeamCreateParamsSchema 357 + const scopeTeam = params['scopeTeam'] 358 + if (scopeTeam) { 359 + const [scope, team] = scopeTeam.split(':') 360 + if (scope && team) { 361 + const normalizedScope = scope.startsWith('@') ? scope : `@${scope}` 362 + if (!this.state.orgs[normalizedScope]) { 363 + this.state.orgs[normalizedScope] = { users: {}, teams: [], teamMembers: {} } 364 + } 365 + if (!this.state.orgs[normalizedScope].teams.includes(team)) { 366 + this.state.orgs[normalizedScope].teams.push(team) 367 + } 368 + this.state.orgs[normalizedScope].teamMembers[team] = [] 369 + } 370 + } 371 + break 372 + } 373 + case 'team:destroy': { 374 + // Params: { scopeTeam } — TeamDestroyParamsSchema 375 + const scopeTeam = params['scopeTeam'] 376 + if (scopeTeam) { 377 + const [scope, team] = scopeTeam.split(':') 378 + if (scope && team) { 379 + const normalizedScope = scope.startsWith('@') ? scope : `@${scope}` 380 + if (this.state.orgs[normalizedScope]) { 381 + this.state.orgs[normalizedScope].teams = this.state.orgs[ 382 + normalizedScope 383 + ].teams.filter(t => t !== team) 384 + delete this.state.orgs[normalizedScope].teamMembers[team] 385 + } 386 + } 387 + } 388 + break 389 + } 390 + case 'team:add-user': { 391 + // Params: { scopeTeam, user } — TeamAddUserParamsSchema 392 + const scopeTeam = params['scopeTeam'] 393 + const user = params['user'] 394 + if (scopeTeam && user) { 395 + const [scope, team] = scopeTeam.split(':') 396 + if (scope && team) { 397 + const normalizedScope = scope.startsWith('@') ? scope : `@${scope}` 398 + if (this.state.orgs[normalizedScope]) { 399 + const members = this.state.orgs[normalizedScope].teamMembers[team] ?? [] 400 + if (!members.includes(user)) { 401 + members.push(user) 402 + } 403 + this.state.orgs[normalizedScope].teamMembers[team] = members 404 + } 405 + } 406 + } 407 + break 408 + } 409 + case 'team:rm-user': { 410 + // Params: { scopeTeam, user } — TeamRemoveUserParamsSchema 411 + const scopeTeam = params['scopeTeam'] 412 + const user = params['user'] 413 + if (scopeTeam && user) { 414 + const [scope, team] = scopeTeam.split(':') 415 + if (scope && team) { 416 + const normalizedScope = scope.startsWith('@') ? scope : `@${scope}` 417 + if (this.state.orgs[normalizedScope]) { 418 + const members = this.state.orgs[normalizedScope].teamMembers[team] 419 + if (members) { 420 + this.state.orgs[normalizedScope].teamMembers[team] = members.filter(u => u !== user) 421 + } 422 + } 423 + } 424 + } 425 + break 426 + } 427 + case 'access:grant': { 428 + // Params: { permission, scopeTeam, pkg } — AccessGrantParamsSchema 429 + const pkg = params['pkg'] 430 + const scopeTeam = params['scopeTeam'] 431 + const permission = (params['permission'] as AccessPermission) ?? 'read-write' 432 + if (pkg && scopeTeam) { 433 + if (!this.state.packages[pkg]) { 434 + this.state.packages[pkg] = { collaborators: {} } 435 + } 436 + this.state.packages[pkg].collaborators[scopeTeam] = permission 437 + } 438 + break 439 + } 440 + case 'access:revoke': { 441 + // Params: { scopeTeam, pkg } — AccessRevokeParamsSchema 442 + const pkg = params['pkg'] 443 + const scopeTeam = params['scopeTeam'] 444 + if (pkg && scopeTeam && this.state.packages[pkg]) { 445 + delete this.state.packages[pkg].collaborators[scopeTeam] 446 + } 447 + break 448 + } 449 + case 'owner:add': { 450 + // Params: { user, pkg } — OwnerAddParamsSchema 451 + const pkg = params['pkg'] 452 + const user = params['user'] 453 + if (pkg && user) { 454 + if (!this.state.packages[pkg]) { 455 + this.state.packages[pkg] = { collaborators: {} } 456 + } 457 + this.state.packages[pkg].collaborators[user] = 'read-write' 458 + } 459 + break 460 + } 461 + case 'owner:rm': { 462 + // Params: { user, pkg } — OwnerRemoveParamsSchema 463 + const pkg = params['pkg'] 464 + const user = params['user'] 465 + if (pkg && user && this.state.packages[pkg]) { 466 + delete this.state.packages[pkg].collaborators[user] 467 + } 468 + break 469 + } 470 + case 'package:init': { 471 + // Params: { name, author? } — PackageInitParamsSchema 472 + const name = params['name'] 473 + if (name) { 474 + this.state.packages[name] = { 475 + collaborators: { [this.state.config.npmUser]: 'read-write' }, 476 + } 477 + this.state.userPackages[name] = 'read-write' 478 + } 479 + break 480 + } 481 + } 482 + } 483 + 484 + /** Topological sort by dependsOn. */ 485 + private sortByDependencies(operations: PendingOperation[]): PendingOperation[] { 486 + const result: PendingOperation[] = [] 487 + const visited = new Set<string>() 488 + 489 + const visit = (op: PendingOperation) => { 490 + if (visited.has(op.id)) return 491 + visited.add(op.id) 492 + 493 + if (op.dependsOn) { 494 + const dep = operations.find(d => d.id === op.dependsOn) 495 + if (dep) visit(dep) 496 + } 497 + 498 + result.push(op) 499 + } 500 + 501 + for (const op of operations) { 502 + visit(op) 503 + } 504 + 505 + return result 506 + } 507 + 508 + reset(): void { 509 + this.state.connected = false 510 + this.state.connectedAt = null 511 + this.state.orgs = {} 512 + this.state.packages = {} 513 + this.state.userPackages = {} 514 + this.state.userOrgs = [] 515 + this.state.operations = [] 516 + this.state.operationIdCounter = 0 517 + } 518 + } 519 + 520 + /** @internal */ 521 + export const DEFAULT_MOCK_CONFIG: MockConnectorConfig = { 522 + token: 'test-token-e2e-12345', 523 + npmUser: 'testuser', 524 + avatar: null, 525 + port: 31415, 526 + }
+44 -17
cli/src/server.ts
··· 3 3 import type { CorsOptions } from 'h3-next' 4 4 import * as v from 'valibot' 5 5 6 - import type { ConnectorState, PendingOperation, ApiResponse } from './types.ts' 6 + import type { 7 + ConnectorState, 8 + PendingOperation, 9 + ApiResponse, 10 + ConnectorEndpoints, 11 + AssertEndpointsImplemented, 12 + } from './types.ts' 13 + 14 + // Endpoint completeness check — errors if this list diverges from ConnectorEndpoints. 15 + const _endpointCheck: AssertEndpointsImplemented< 16 + | 'POST /connect' 17 + | 'GET /state' 18 + | 'POST /operations' 19 + | 'POST /operations/batch' 20 + | 'DELETE /operations' 21 + | 'DELETE /operations/all' 22 + | 'POST /approve' 23 + | 'POST /approve-all' 24 + | 'POST /retry' 25 + | 'POST /execute' 26 + | 'GET /org/:org/users' 27 + | 'GET /org/:org/teams' 28 + | 'GET /team/:scopeTeam/users' 29 + | 'GET /package/:pkg/collaborators' 30 + | 'GET /user/packages' 31 + | 'GET /user/orgs' 32 + > = true 33 + void _endpointCheck 7 34 import { logDebug, logError } from './logger.ts' 8 35 import { 9 36 getNpmUser, ··· 108 135 avatar, 109 136 connectedAt: state.session.connectedAt, 110 137 }, 111 - } as ApiResponse 138 + } satisfies ApiResponse<ConnectorEndpoints['POST /connect']['data']> 112 139 }) 113 140 114 141 app.get('/state', event => { ··· 124 151 avatar: state.session.avatar, 125 152 operations: state.operations, 126 153 }, 127 - } as ApiResponse 154 + } satisfies ApiResponse<ConnectorEndpoints['GET /state']['data']> 128 155 }) 129 156 130 157 app.post('/operations', async event => { ··· 164 191 return { 165 192 success: true, 166 193 data: operation, 167 - } as ApiResponse 194 + } satisfies ApiResponse<ConnectorEndpoints['POST /operations']['data']> 168 195 }) 169 196 170 197 app.post('/operations/batch', async event => { ··· 212 239 return { 213 240 success: true, 214 241 data: created, 215 - } as ApiResponse 242 + } satisfies ApiResponse<ConnectorEndpoints['POST /operations/batch']['data']> 216 243 }) 217 244 218 245 app.post('/approve', event => { ··· 246 273 return { 247 274 success: true, 248 275 data: operation, 249 - } as ApiResponse 276 + } satisfies ApiResponse<ConnectorEndpoints['POST /approve']['data']> 250 277 }) 251 278 252 279 app.post('/approve-all', event => { ··· 263 290 return { 264 291 success: true, 265 292 data: { approved: pendingOps.length }, 266 - } as ApiResponse 293 + } satisfies ApiResponse<ConnectorEndpoints['POST /approve-all']['data']> 267 294 }) 268 295 269 296 app.post('/retry', event => { ··· 299 326 return { 300 327 success: true, 301 328 data: operation, 302 - } as ApiResponse 329 + } satisfies ApiResponse<ConnectorEndpoints['POST /retry']['data']> 303 330 }) 304 331 305 332 app.post('/execute', async event => { ··· 397 424 otpRequired, 398 425 authFailure, 399 426 }, 400 - } as ApiResponse 427 + } satisfies ApiResponse<ConnectorEndpoints['POST /execute']['data']> 401 428 }) 402 429 403 430 app.delete('/operations', event => { ··· 429 456 430 457 state.operations.splice(index, 1) 431 458 432 - return { success: true } as ApiResponse 459 + return { success: true } satisfies ApiResponse<ConnectorEndpoints['DELETE /operations']['data']> 433 460 }) 434 461 435 462 app.delete('/operations/all', event => { ··· 444 471 return { 445 472 success: true, 446 473 data: { removed }, 447 - } as ApiResponse 474 + } satisfies ApiResponse<ConnectorEndpoints['DELETE /operations/all']['data']> 448 475 }) 449 476 450 477 // List endpoints (read-only data fetching) ··· 474 501 return { 475 502 success: true, 476 503 data: users, 477 - } as ApiResponse 504 + } satisfies ApiResponse<ConnectorEndpoints['GET /org/:org/users']['data']> 478 505 } catch { 479 506 return { 480 507 success: false, ··· 508 535 return { 509 536 success: true, 510 537 data: teams, 511 - } as ApiResponse 538 + } satisfies ApiResponse<ConnectorEndpoints['GET /org/:org/teams']['data']> 512 539 } catch { 513 540 return { 514 541 success: false, ··· 554 581 return { 555 582 success: true, 556 583 data: users, 557 - } as ApiResponse 584 + } satisfies ApiResponse<ConnectorEndpoints['GET /team/:scopeTeam/users']['data']> 558 585 } catch { 559 586 return { 560 587 success: false, ··· 595 622 return { 596 623 success: true, 597 624 data: collaborators, 598 - } as ApiResponse 625 + } satisfies ApiResponse<ConnectorEndpoints['GET /package/:pkg/collaborators']['data']> 599 626 } catch { 600 627 return { 601 628 success: false, ··· 634 661 return { 635 662 success: true, 636 663 data: packages, 637 - } as ApiResponse 664 + } satisfies ApiResponse<ConnectorEndpoints['GET /user/packages']['data']> 638 665 } catch { 639 666 return { 640 667 success: false, ··· 686 713 return { 687 714 success: true, 688 715 data: Array.from(orgs).sort(), 689 - } as ApiResponse 716 + } satisfies ApiResponse<ConnectorEndpoints['GET /user/orgs']['data']> 690 717 } catch { 691 718 return { 692 719 success: false,
+80
cli/src/types.ts
··· 66 66 data?: T 67 67 error?: string 68 68 } 69 + 70 + // -- Connector API contract (shared by real + mock server) ------------------- 71 + 72 + export type OrgRole = 'developer' | 'admin' | 'owner' 73 + 74 + export type AccessPermission = 'read-only' | 'read-write' 75 + 76 + /** POST /connect response data */ 77 + export interface ConnectResponseData { 78 + npmUser: string | null 79 + avatar: string | null 80 + connectedAt: number 81 + } 82 + 83 + /** GET /state response data */ 84 + export interface StateResponseData { 85 + npmUser: string | null 86 + avatar: string | null 87 + operations: PendingOperation[] 88 + } 89 + 90 + /** POST /execute response data */ 91 + export interface ExecuteResponseData { 92 + results: Array<{ id: string; result: OperationResult }> 93 + otpRequired?: boolean 94 + authFailure?: boolean 95 + } 96 + 97 + /** POST /approve-all response data */ 98 + export interface ApproveAllResponseData { 99 + approved: number 100 + } 101 + 102 + /** DELETE /operations/all response data */ 103 + export interface ClearOperationsResponseData { 104 + removed: number 105 + } 106 + 107 + /** Request body for POST /operations */ 108 + export interface CreateOperationBody { 109 + type: OperationType 110 + params: Record<string, string> 111 + description: string 112 + command: string 113 + dependsOn?: string 114 + } 115 + 116 + /** 117 + * Connector API endpoint contract. Both server.ts and mock-app.ts must 118 + * conform to these shapes, enforced via `satisfies` and `AssertEndpointsImplemented`. 119 + */ 120 + export interface ConnectorEndpoints { 121 + 'POST /connect': { body: { token: string }; data: ConnectResponseData } 122 + 'GET /state': { body: never; data: StateResponseData } 123 + 'POST /operations': { body: CreateOperationBody; data: PendingOperation } 124 + 'POST /operations/batch': { body: CreateOperationBody[]; data: PendingOperation[] } 125 + 'DELETE /operations': { body: never; data: void } 126 + 'DELETE /operations/all': { body: never; data: ClearOperationsResponseData } 127 + 'POST /approve': { body: never; data: PendingOperation } 128 + 'POST /approve-all': { body: never; data: ApproveAllResponseData } 129 + 'POST /retry': { body: never; data: PendingOperation } 130 + 'POST /execute': { body: { otp?: string }; data: ExecuteResponseData } 131 + 'GET /org/:org/users': { body: never; data: Record<string, OrgRole> } 132 + 'GET /org/:org/teams': { body: never; data: string[] } 133 + 'GET /team/:scopeTeam/users': { body: never; data: string[] } 134 + 'GET /package/:pkg/collaborators': { body: never; data: Record<string, AccessPermission> } 135 + 'GET /user/packages': { body: never; data: Record<string, AccessPermission> } 136 + 'GET /user/orgs': { body: never; data: string[] } 137 + } 138 + 139 + /** Compile-time check that a server implements exactly the ConnectorEndpoints keys. */ 140 + type IsExact<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false 141 + export type AssertEndpointsImplemented<Implemented extends string> = 142 + IsExact<Implemented, keyof ConnectorEndpoints> extends true 143 + ? true 144 + : { 145 + error: 'Endpoint mismatch' 146 + missing: Exclude<keyof ConnectorEndpoints, Implemented> 147 + extra: Exclude<Implemented, keyof ConnectorEndpoints> 148 + }
+1
cli/tsconfig.json
··· 8 8 "noEmit": true, 9 9 "allowImportingTsExtensions": true, 10 10 "declaration": true, 11 + "types": ["node"], 11 12 "declarationMap": true 12 13 }, 13 14 "include": ["src/**/*.ts"],
+11 -2
knip.ts
··· 27 27 'uno-preset-rtl.ts!', 28 28 'scripts/**/*.ts', 29 29 ], 30 - project: ['**/*.{ts,vue,cjs,mjs}', '!test/fixtures/**'], 30 + project: [ 31 + '**/*.{ts,vue,cjs,mjs}', 32 + '!test/fixtures/**', 33 + '!test/test-utils/**', 34 + '!test/e2e/helpers/**', 35 + '!cli/src/**', 36 + ], 31 37 ignoreDependencies: [ 32 38 '@iconify-json/*', 33 39 '@voidzero-dev/vite-plus-core', ··· 44 50 /** Oxlint plugins don't get picked up yet */ 45 51 '@e18e/eslint-plugin', 46 52 'eslint-plugin-regexp', 53 + 54 + /** Used in test/e2e/helpers/ which is excluded from knip project scope */ 55 + 'h3-next', 47 56 ], 48 57 ignoreUnresolved: ['#components', '#oauth/config'], 49 58 }, 50 59 'cli': { 51 - project: ['src/**/*.ts!'], 60 + project: ['src/**/*.ts!', '!src/mock-*.ts'], 52 61 }, 53 62 'docs': { 54 63 entry: ['app/**/*.{ts,vue}'],
+9 -1
nuxt.config.ts
··· 280 280 compilerOptions: { 281 281 noUnusedLocals: true, 282 282 allowImportingTsExtensions: true, 283 + paths: { 284 + '#cli/*': ['../cli/src/*'], 285 + }, 283 286 }, 284 287 include: ['../test/unit/app/**/*.ts'], 285 288 }, ··· 289 292 nodeTsConfig: { 290 293 compilerOptions: { 291 294 allowImportingTsExtensions: true, 295 + paths: { 296 + '#cli/*': ['../cli/src/*'], 297 + '#server/*': ['../server/*'], 298 + '#shared/*': ['../shared/*'], 299 + }, 292 300 }, 293 - include: ['../*.ts'], 301 + include: ['../*.ts', '../test/e2e/**/*.ts'], 294 302 }, 295 303 }, 296 304
+2
package.json
··· 27 27 "lint:css": "node scripts/unocss-checker.ts", 28 28 "generate": "nuxt generate", 29 29 "npmx-connector": "pnpm --filter npmx-connector dev", 30 + "mock-connector": "pnpm --filter npmx-connector dev:mock", 30 31 "generate-pwa-icons": "pwa-assets-generator", 31 32 "preview": "nuxt preview", 32 33 "postinstall": "pnpm rebuild @resvg/resvg-js && pnpm generate:lexicons && pnpm generate:sprite && nuxt prepare && simple-git-hooks", ··· 127 128 "eslint-plugin-regexp": "3.0.0", 128 129 "fast-check": "4.5.3", 129 130 "h3": "1.15.5", 131 + "h3-next": "npm:h3@2.0.1-rc.11", 130 132 "knip": "5.83.0", 131 133 "lint-staged": "16.2.7", 132 134 "oxfmt": "0.27.0",
+2
playwright.config.ts
··· 18 18 reuseExistingServer: false, 19 19 timeout: 60_000, 20 20 }, 21 + // Start/stop mock connector server before/after all tests (teardown via returned closure) 22 + globalSetup: fileURLToPath(new URL('./test/e2e/global-setup.ts', import.meta.url)), 21 23 // We currently only test on one browser on one platform 22 24 snapshotPathTemplate: '{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{ext}', 23 25 use: {
+3
pnpm-lock.yaml
··· 249 249 h3: 250 250 specifier: 1.15.5 251 251 version: 1.15.5 252 + h3-next: 253 + specifier: npm:h3@2.0.1-rc.11 254 + version: h3@2.0.1-rc.11 252 255 knip: 253 256 specifier: 5.83.0 254 257 version: 5.83.0(@types/node@24.10.9)(typescript@5.9.3)
+451
test/e2e/connector.spec.ts
··· 1 + /** 2 + * E2E tests for connector-authenticated features. 3 + * 4 + * These tests use a mock connector server (started in global setup) 5 + * to test features that require being logged in via the connector. 6 + * 7 + * All tests run serially because they share a single mock connector server 8 + * whose state is reset before each test via `mockConnector.reset()`. 9 + */ 10 + 11 + import type { Page } from '@playwright/test' 12 + import { test, expect } from './helpers/fixtures' 13 + 14 + test.describe.configure({ mode: 'serial' }) 15 + 16 + /** 17 + * When connected, the header shows "packages" and "orgs" links scoped to the user. 18 + * This helper waits for the packages link to appear as proof of successful connection. 19 + */ 20 + async function expectConnected(page: Page, username = 'testuser') { 21 + await expect(page.locator(`a[href="/~${username}"]`, { hasText: 'packages' })).toBeVisible({ 22 + timeout: 10_000, 23 + }) 24 + } 25 + 26 + /** 27 + * Open the connector modal by clicking the account menu button, then clicking 28 + * the npm CLI menu item inside the dropdown. 29 + */ 30 + async function openConnectorModal(page: Page) { 31 + // The AccountMenu button has aria-haspopup="true" 32 + await page.locator('button[aria-haspopup="true"]').click() 33 + 34 + // In the dropdown menu, click the npm CLI item (menuitem containing ~testuser) 35 + await page 36 + .getByRole('menuitem') 37 + .filter({ hasText: /~testuser/ }) 38 + .click() 39 + 40 + // Wait for the dialog to appear 41 + await expect(page.getByRole('dialog')).toBeVisible() 42 + } 43 + 44 + test.describe('Connector Connection', () => { 45 + test('connects via URL params and shows connected state', async ({ 46 + page, 47 + gotoConnected, 48 + mockConnector, 49 + }) => { 50 + await mockConnector.setUserOrgs(['@testorg']) 51 + await gotoConnected('/') 52 + 53 + // Header should show "packages" link for the connected user 54 + await expectConnected(page) 55 + }) 56 + 57 + test('opens connector modal and shows connected user', async ({ page, gotoConnected }) => { 58 + await gotoConnected('/') 59 + await expectConnected(page) 60 + 61 + await openConnectorModal(page) 62 + 63 + // The modal should show the connected user 64 + await expect(page.getByRole('dialog')).toContainText('testuser') 65 + }) 66 + 67 + test('can disconnect from the connector', async ({ page, gotoConnected }) => { 68 + await gotoConnected('/') 69 + await expectConnected(page) 70 + 71 + await openConnectorModal(page) 72 + 73 + const modal = page.getByRole('dialog') 74 + 75 + // Click disconnect button 76 + await modal.getByRole('button', { name: /disconnect/i }).click() 77 + 78 + // Close the modal 79 + await modal.getByRole('button', { name: /close/i }).click() 80 + 81 + // The "packages" link should disappear since we're disconnected 82 + await expect(page.locator('a[href="/~testuser"]', { hasText: 'packages' })).not.toBeVisible({ 83 + timeout: 5000, 84 + }) 85 + 86 + // The account menu button should now show "connect" text (the main button, not dropdown items) 87 + await expect(page.getByRole('button', { name: 'connect', exact: true })).toBeVisible() 88 + }) 89 + }) 90 + 91 + test.describe('Organization Management', () => { 92 + test.beforeEach(async ({ mockConnector }) => { 93 + await mockConnector.setOrgData('@testorg', { 94 + users: { 95 + testuser: 'owner', 96 + member1: 'admin', 97 + member2: 'developer', 98 + }, 99 + teams: ['core', 'docs'], 100 + teamMembers: { 101 + core: ['testuser', 'member1'], 102 + docs: ['member2'], 103 + }, 104 + }) 105 + await mockConnector.setUserOrgs(['@testorg']) 106 + }) 107 + 108 + test('shows org members when connected', async ({ page, gotoConnected }) => { 109 + await gotoConnected('/@testorg') 110 + 111 + // The org management region contains the members panel 112 + const orgManagement = page.getByRole('region', { name: /organization management/i }) 113 + await expect(orgManagement).toBeVisible({ timeout: 10_000 }) 114 + 115 + // Should show the members list 116 + const membersList = page.getByRole('list', { name: /organization members/i }) 117 + await expect(membersList).toBeVisible({ timeout: 10_000 }) 118 + 119 + // Members are shown as ~username links 120 + await expect(membersList.getByRole('link', { name: '~testuser' })).toBeVisible() 121 + await expect(membersList.getByRole('link', { name: '~member1' })).toBeVisible() 122 + await expect(membersList.getByRole('link', { name: '~member2' })).toBeVisible() 123 + }) 124 + 125 + test('can filter members by role', async ({ page, gotoConnected }) => { 126 + await gotoConnected('/@testorg') 127 + 128 + const orgManagement = page.getByRole('region', { name: /organization management/i }) 129 + await expect(orgManagement).toBeVisible({ timeout: 10_000 }) 130 + 131 + const membersList = page.getByRole('list', { name: /organization members/i }) 132 + await expect(membersList).toBeVisible({ timeout: 10_000 }) 133 + 134 + // Click the "admin" filter button (inside "Filter by role" group) 135 + await orgManagement 136 + .getByRole('group', { name: /filter by role/i }) 137 + .getByRole('button', { name: /admin/i }) 138 + .click() 139 + 140 + // Should only show admin member 141 + await expect(membersList.getByRole('link', { name: '~member1' })).toBeVisible() 142 + await expect(membersList.getByRole('link', { name: '~testuser' })).not.toBeVisible() 143 + await expect(membersList.getByRole('link', { name: '~member2' })).not.toBeVisible() 144 + }) 145 + 146 + test('can search members by name', async ({ page, gotoConnected }) => { 147 + await gotoConnected('/@testorg') 148 + 149 + const orgManagement = page.getByRole('region', { name: /organization management/i }) 150 + await expect(orgManagement).toBeVisible({ timeout: 10_000 }) 151 + 152 + const membersList = page.getByRole('list', { name: /organization members/i }) 153 + await expect(membersList).toBeVisible({ timeout: 10_000 }) 154 + 155 + const searchInput = orgManagement.getByRole('searchbox', { name: /filter members/i }) 156 + await searchInput.fill('member1') 157 + 158 + // Should only show matching member 159 + await expect(membersList.getByRole('link', { name: '~member1' })).toBeVisible() 160 + await expect(membersList.getByRole('link', { name: '~testuser' })).not.toBeVisible() 161 + await expect(membersList.getByRole('link', { name: '~member2' })).not.toBeVisible() 162 + }) 163 + 164 + test('can add a new member operation', async ({ page, gotoConnected, mockConnector }) => { 165 + await gotoConnected('/@testorg') 166 + 167 + const orgManagement = page.getByRole('region', { name: /organization management/i }) 168 + await expect(orgManagement).toBeVisible({ timeout: 10_000 }) 169 + 170 + // Click "Add member" button 171 + await orgManagement.getByRole('button', { name: /add member/i }).click() 172 + 173 + // Wait for the add-member form to appear 174 + const usernameInput = orgManagement.locator('#new-member-username') 175 + await expect(usernameInput).toBeVisible({ timeout: 5000 }) 176 + 177 + // Fill in the form 178 + await usernameInput.fill('newuser') 179 + 180 + // Select role (SelectField renders id on the <select>, not name) 181 + await orgManagement.locator('#new-member-role').selectOption('admin') 182 + 183 + // Submit 184 + await Promise.all([ 185 + page.waitForResponse(resp => resp.url().includes('/operations') && resp.ok()), 186 + orgManagement.getByRole('button', { name: /^add$/i }).click(), 187 + ]) 188 + 189 + const operations = await mockConnector.getOperations() 190 + expect(operations).toHaveLength(1) 191 + expect(operations[0]?.type).toBe('org:add-user') 192 + expect(operations[0]?.params.user).toBe('newuser') 193 + expect(operations[0]?.params.role).toBe('admin') 194 + }) 195 + 196 + test('can remove a member (adds operation)', async ({ page, gotoConnected, mockConnector }) => { 197 + await gotoConnected('/@testorg') 198 + 199 + const orgManagement = page.getByRole('region', { name: /organization management/i }) 200 + await expect(orgManagement).toBeVisible({ timeout: 10_000 }) 201 + 202 + await Promise.all([ 203 + page.waitForResponse(resp => resp.url().includes('/operations') && resp.ok()), 204 + orgManagement.getByRole('button', { name: /remove member2/i }).click(), 205 + ]) 206 + 207 + const operations = await mockConnector.getOperations() 208 + expect(operations).toHaveLength(1) 209 + expect(operations[0]?.type).toBe('org:rm-user') 210 + expect(operations[0]?.params.user).toBe('member2') 211 + }) 212 + 213 + test('can change member role (adds operation)', async ({ 214 + page, 215 + gotoConnected, 216 + mockConnector, 217 + }) => { 218 + await gotoConnected('/@testorg') 219 + 220 + const orgManagement = page.getByRole('region', { name: /organization management/i }) 221 + await expect(orgManagement).toBeVisible({ timeout: 10_000 }) 222 + 223 + const roleSelect = orgManagement.locator('#role-member2') 224 + await expect(roleSelect).toBeVisible({ timeout: 5000 }) 225 + await Promise.all([ 226 + page.waitForResponse(resp => resp.url().includes('/operations') && resp.ok()), 227 + roleSelect.selectOption('admin'), 228 + ]) 229 + 230 + const operations = await mockConnector.getOperations() 231 + expect(operations).toHaveLength(1) 232 + expect(operations[0]?.type).toBe('org:add-user') 233 + expect(operations[0]?.params.user).toBe('member2') 234 + expect(operations[0]?.params.role).toBe('admin') 235 + }) 236 + }) 237 + 238 + test.describe('Package Access Controls', () => { 239 + test.beforeEach(async ({ mockConnector }) => { 240 + await mockConnector.setOrgData('@nuxt', { 241 + users: { testuser: 'owner' }, 242 + teams: ['core', 'docs', 'triage'], 243 + }) 244 + await mockConnector.setUserOrgs(['@nuxt']) 245 + await mockConnector.setPackageData('@nuxt/kit', { 246 + collaborators: { 247 + 'nuxt:core': 'read-write', 248 + 'nuxt:docs': 'read-only', 249 + }, 250 + }) 251 + }) 252 + 253 + /** 254 + * Helper: connect on home page then navigate to the package page. 255 + * Verifies connection is established before navigating. 256 + */ 257 + async function goToPackageConnected(page: Page, gotoConnected: (path: string) => Promise<void>) { 258 + await gotoConnected('/') 259 + await expectConnected(page) 260 + await page.goto('/package/@nuxt/kit') 261 + await expect(page.locator('h1')).toContainText('kit', { timeout: 30_000 }) 262 + } 263 + 264 + test('shows team access section on scoped package when connected', async ({ 265 + page, 266 + gotoConnected, 267 + }) => { 268 + await goToPackageConnected(page, gotoConnected) 269 + 270 + await expect(accessSection(page)).toBeVisible({ timeout: 15_000 }) 271 + await expect(page.getByRole('heading', { name: /team access/i })).toBeVisible() 272 + }) 273 + 274 + test('displays collaborators with correct permissions', async ({ page, gotoConnected }) => { 275 + await goToPackageConnected(page, gotoConnected) 276 + 277 + await expect(accessSection(page)).toBeVisible({ timeout: 15_000 }) 278 + 279 + const teamList = page.getByRole('list', { name: /team access list/i }) 280 + await expect(teamList).toBeVisible({ timeout: 10_000 }) 281 + 282 + await expect(teamList.getByText('core')).toBeVisible() 283 + await expect(teamList.locator('span', { hasText: 'rw' })).toBeVisible() 284 + await expect(teamList.getByText('docs')).toBeVisible() 285 + await expect(teamList.locator('span', { hasText: 'ro' })).toBeVisible() 286 + }) 287 + 288 + test('can grant team access (creates operation)', async ({ 289 + page, 290 + gotoConnected, 291 + mockConnector, 292 + }) => { 293 + await goToPackageConnected(page, gotoConnected) 294 + 295 + const section = accessSection(page) 296 + await expect(section).toBeVisible({ timeout: 15_000 }) 297 + 298 + await section.getByRole('button', { name: /grant team access/i }).click() 299 + 300 + const teamSelect = section.locator('#grant-team-select') 301 + await expect(teamSelect).toBeVisible() 302 + await expect(teamSelect.locator('option')).toHaveCount(4, { timeout: 10_000 }) 303 + await teamSelect.selectOption({ label: 'nuxt:triage' }) 304 + 305 + await section.locator('#grant-permission-select').selectOption('read-write') 306 + 307 + await Promise.all([ 308 + page.waitForResponse(resp => resp.url().includes('/operations') && resp.ok()), 309 + section.getByRole('button', { name: /^grant$/i }).click(), 310 + ]) 311 + 312 + const operations = await mockConnector.getOperations() 313 + expect(operations).toHaveLength(1) 314 + expect(operations[0]?.type).toBe('access:grant') 315 + expect(operations[0]?.params.scopeTeam).toBe('@nuxt:triage') 316 + expect(operations[0]?.params.pkg).toBe('@nuxt/kit') 317 + expect(operations[0]?.params.permission).toBe('read-write') 318 + }) 319 + 320 + test('can revoke team access (creates operation)', async ({ 321 + page, 322 + gotoConnected, 323 + mockConnector, 324 + }) => { 325 + await goToPackageConnected(page, gotoConnected) 326 + 327 + const section = accessSection(page) 328 + await expect(section).toBeVisible({ timeout: 15_000 }) 329 + 330 + const teamList = page.getByRole('list', { name: /team access list/i }) 331 + await expect(teamList).toBeVisible({ timeout: 10_000 }) 332 + 333 + await Promise.all([ 334 + page.waitForResponse(resp => resp.url().includes('/operations') && resp.ok()), 335 + section.getByRole('button', { name: /revoke docs access/i }).click(), 336 + ]) 337 + 338 + const operations = await mockConnector.getOperations() 339 + expect(operations).toHaveLength(1) 340 + expect(operations[0]?.type).toBe('access:revoke') 341 + expect(operations[0]?.params.scopeTeam).toBe('nuxt:docs') 342 + expect(operations[0]?.params.pkg).toBe('@nuxt/kit') 343 + }) 344 + 345 + test('can cancel grant access form', async ({ page, gotoConnected }) => { 346 + await goToPackageConnected(page, gotoConnected) 347 + 348 + const section = accessSection(page) 349 + await expect(section).toBeVisible({ timeout: 15_000 }) 350 + 351 + await section.getByRole('button', { name: /grant team access/i }).click() 352 + const teamSelect = section.locator('#grant-team-select') 353 + await expect(teamSelect).toBeVisible() 354 + 355 + await section.getByRole('button', { name: /cancel granting access/i }).click() 356 + await expect(teamSelect).not.toBeVisible() 357 + await expect(section.getByRole('button', { name: /grant team access/i })).toBeVisible() 358 + }) 359 + }) 360 + 361 + test.describe('Operations Queue', () => { 362 + test('shows operations in connector modal', async ({ page, gotoConnected, mockConnector }) => { 363 + await mockConnector.addOperation({ 364 + type: 'org:add-user', 365 + params: { org: '@testorg', user: 'newuser', role: 'developer' }, 366 + description: 'Add @newuser to @testorg as developer', 367 + command: 'npm org set @testorg newuser developer', 368 + }) 369 + await mockConnector.addOperation({ 370 + type: 'org:rm-user', 371 + params: { org: '@testorg', user: 'olduser' }, 372 + description: 'Remove @olduser from @testorg', 373 + command: 'npm org rm @testorg olduser', 374 + }) 375 + 376 + await gotoConnected('/') 377 + await expectConnected(page) 378 + 379 + await openConnectorModal(page) 380 + 381 + const modal = page.getByRole('dialog') 382 + await expect(modal).toContainText('Add @newuser') 383 + await expect(modal).toContainText('Remove @olduser') 384 + }) 385 + 386 + test('can approve and execute operations', async ({ page, gotoConnected, mockConnector }) => { 387 + await mockConnector.addOperation({ 388 + type: 'org:add-user', 389 + params: { org: '@testorg', user: 'newuser', role: 'developer' }, 390 + description: 'Add @newuser to @testorg', 391 + command: 'npm org set @testorg newuser developer', 392 + }) 393 + 394 + await gotoConnected('/') 395 + await expectConnected(page) 396 + 397 + await openConnectorModal(page) 398 + 399 + const modal = page.getByRole('dialog') 400 + 401 + // Approve all 402 + const approveAllBtn = modal.getByRole('button', { name: /approve all/i }) 403 + await expect(approveAllBtn).toBeVisible({ timeout: 5000 }) 404 + await Promise.all([ 405 + page.waitForResponse(resp => resp.url().includes('/approve-all') && resp.ok()), 406 + approveAllBtn.click(), 407 + ]) 408 + 409 + let operations = await mockConnector.getOperations() 410 + expect(operations[0]?.status).toBe('approved') 411 + 412 + // Execute 413 + const executeBtn = modal.getByRole('button', { name: /execute/i }) 414 + await expect(executeBtn).toBeVisible({ timeout: 5000 }) 415 + await Promise.all([ 416 + page.waitForResponse(resp => resp.url().includes('/execute') && resp.ok()), 417 + executeBtn.click(), 418 + ]) 419 + 420 + operations = await mockConnector.getOperations() 421 + expect(operations[0]?.status).toBe('completed') 422 + }) 423 + 424 + test('can clear pending operations', async ({ page, gotoConnected, mockConnector }) => { 425 + await mockConnector.addOperation({ 426 + type: 'org:add-user', 427 + params: { org: '@testorg', user: 'newuser', role: 'developer' }, 428 + description: 'Add @newuser to @testorg', 429 + command: 'npm org set @testorg newuser developer', 430 + }) 431 + 432 + await gotoConnected('/') 433 + await expectConnected(page) 434 + 435 + await openConnectorModal(page) 436 + 437 + const modal = page.getByRole('dialog') 438 + await Promise.all([ 439 + page.waitForResponse(resp => resp.url().includes('/operations/all') && resp.ok()), 440 + modal.getByRole('button', { name: /clear/i }).click(), 441 + ]) 442 + 443 + const operations = await mockConnector.getOperations() 444 + expect(operations).toHaveLength(0) 445 + }) 446 + }) 447 + 448 + /** The access section is identified by the "Team Access" heading */ 449 + function accessSection(page: Page) { 450 + return page.locator('section:has(#access-heading)') 451 + }
+35
test/e2e/global-setup.ts
··· 1 + /** 2 + * Playwright global setup - starts the mock connector server before all tests. 3 + * 4 + * Returns an async teardown function (Playwright's recommended pattern for 5 + * sharing state between setup and teardown via closure). 6 + */ 7 + 8 + // eslint-disable no-console 9 + 10 + import { MockConnectorServer, DEFAULT_MOCK_CONFIG } from './helpers/mock-connector' 11 + 12 + export default async function globalSetup() { 13 + console.log('[Global Setup] Starting mock connector server...') 14 + 15 + const mockServer = new MockConnectorServer(DEFAULT_MOCK_CONFIG) 16 + 17 + try { 18 + await mockServer.start() 19 + console.log(`[Global Setup] Mock connector ready at http://127.0.0.1:${mockServer.port}`) 20 + console.log(`[Global Setup] Test token: ${mockServer.token}`) 21 + } catch (error) { 22 + console.error('[Global Setup] Failed to start mock connector:', error) 23 + throw error 24 + } 25 + 26 + // Return teardown function — Playwright calls this after all tests complete 27 + return async () => { 28 + console.log('[Global Teardown] Stopping mock connector server...') 29 + try { 30 + await mockServer.stop() 31 + } catch (error) { 32 + console.error('[Global Teardown] Error stopping mock connector:', error) 33 + } 34 + } 35 + }
+151
test/e2e/helpers/fixtures.ts
··· 1 + /** 2 + * Playwright fixtures for connector E2E tests. Extends test-utils.ts 3 + * (which includes external API mocking) with connector-specific helpers. 4 + */ 5 + 6 + import { test as base } from '../test-utils' 7 + import { DEFAULT_MOCK_CONFIG } from './mock-connector' 8 + 9 + const TEST_TOKEN = DEFAULT_MOCK_CONFIG.token 10 + const TEST_PORT = DEFAULT_MOCK_CONFIG.port ?? 31415 11 + 12 + /** 13 + * Helper to make requests to the mock connector server. 14 + * This allows tests to set up state before running. 15 + */ 16 + export class MockConnectorClient { 17 + private token: string 18 + private baseUrl: string 19 + 20 + constructor(token: string, port: number) { 21 + this.token = token 22 + this.baseUrl = `http://127.0.0.1:${port}` 23 + } 24 + 25 + private async request<T>(path: string, options?: RequestInit): Promise<T> { 26 + const response = await fetch(`${this.baseUrl}${path}`, { 27 + ...options, 28 + headers: { 29 + 'Content-Type': 'application/json', 30 + 'Authorization': `Bearer ${this.token}`, 31 + ...options?.headers, 32 + }, 33 + }) 34 + if (!response.ok) { 35 + throw new Error( 36 + `Mock connector request failed: ${response.status} ${response.statusText} (${path})`, 37 + ) 38 + } 39 + return response.json() as Promise<T> 40 + } 41 + 42 + private async testEndpoint(path: string, body: unknown): Promise<void> { 43 + const response = await fetch(`${this.baseUrl}${path}`, { 44 + method: 'POST', 45 + headers: { 'Content-Type': 'application/json' }, 46 + body: JSON.stringify(body), 47 + }) 48 + if (!response.ok) { 49 + throw new Error( 50 + `Mock connector test endpoint failed: ${response.status} ${response.statusText} (${path})`, 51 + ) 52 + } 53 + } 54 + 55 + async reset(): Promise<void> { 56 + await this.testEndpoint('/__test__/reset', {}) 57 + await this.testEndpoint('/connect', { token: this.token }) 58 + } 59 + 60 + async setOrgData( 61 + org: string, 62 + data: { 63 + users?: Record<string, 'developer' | 'admin' | 'owner'> 64 + teams?: string[] 65 + teamMembers?: Record<string, string[]> 66 + }, 67 + ): Promise<void> { 68 + await this.testEndpoint('/__test__/org', { org, ...data }) 69 + } 70 + 71 + async setUserOrgs(orgs: string[]): Promise<void> { 72 + await this.testEndpoint('/__test__/user-orgs', { orgs }) 73 + } 74 + 75 + async setUserPackages(packages: Record<string, 'read-only' | 'read-write'>): Promise<void> { 76 + await this.testEndpoint('/__test__/user-packages', { packages }) 77 + } 78 + 79 + async setPackageData( 80 + pkg: string, 81 + data: { collaborators?: Record<string, 'read-only' | 'read-write'> }, 82 + ): Promise<void> { 83 + await this.testEndpoint('/__test__/package', { package: pkg, ...data }) 84 + } 85 + 86 + async addOperation(operation: { 87 + type: string 88 + params: Record<string, string> 89 + description: string 90 + command: string 91 + dependsOn?: string 92 + }): Promise<{ id: string; status: string }> { 93 + const result = await this.request<{ success: boolean; data: { id: string; status: string } }>( 94 + '/operations', 95 + { 96 + method: 'POST', 97 + body: JSON.stringify(operation), 98 + }, 99 + ) 100 + return result.data 101 + } 102 + 103 + async getOperations(): Promise< 104 + Array<{ id: string; type: string; status: string; params: Record<string, string> }> 105 + > { 106 + const result = await this.request<{ 107 + success: boolean 108 + data: { 109 + operations: Array<{ 110 + id: string 111 + type: string 112 + status: string 113 + params: Record<string, string> 114 + }> 115 + } 116 + }>('/state') 117 + return result.data.operations 118 + } 119 + } 120 + 121 + export interface ConnectorFixtures { 122 + mockConnector: MockConnectorClient 123 + testToken: string 124 + connectorPort: number 125 + /** Navigate to a page pre-authenticated with connector credentials. */ 126 + gotoConnected: (path: string) => Promise<void> 127 + } 128 + 129 + export const test = base.extend<ConnectorFixtures>({ 130 + mockConnector: async ({ page: _ }, use) => { 131 + const client = new MockConnectorClient(TEST_TOKEN, TEST_PORT) 132 + await client.reset() 133 + await use(client) 134 + }, 135 + 136 + testToken: TEST_TOKEN, 137 + 138 + connectorPort: TEST_PORT, 139 + 140 + gotoConnected: async ({ goto, testToken, connectorPort }, use) => { 141 + const navigateConnected = async (path: string) => { 142 + const cleanPath = path.startsWith('/') ? path : `/${path}` 143 + const separator = cleanPath.includes('?') ? '&' : '?' 144 + const urlWithParams = `${cleanPath}${separator}token=${testToken}&port=${connectorPort}` 145 + await goto(urlWithParams, { waitUntil: 'networkidle' }) 146 + } 147 + await use(navigateConnected) 148 + }, 149 + }) 150 + 151 + export { expect } from '@nuxt/test-utils/playwright'
+29
test/e2e/helpers/mock-connector-state.ts
··· 1 + /** Singleton state management for E2E tests. */ 2 + 3 + import { 4 + MockConnectorStateManager, 5 + createMockConnectorState, 6 + DEFAULT_MOCK_CONFIG, 7 + type MockConnectorConfig, 8 + } from '../../../cli/src/mock-state.ts' 9 + 10 + export { MockConnectorStateManager, createMockConnectorState, DEFAULT_MOCK_CONFIG } 11 + export type { MockConnectorConfig } 12 + 13 + let globalStateManager: MockConnectorStateManager | null = null 14 + 15 + export function initGlobalMockState(config: MockConnectorConfig): MockConnectorStateManager { 16 + globalStateManager = new MockConnectorStateManager(createMockConnectorState(config)) 17 + return globalStateManager 18 + } 19 + 20 + export function getGlobalMockState(): MockConnectorStateManager { 21 + if (!globalStateManager) { 22 + throw new Error('Mock connector state not initialized. Call initGlobalMockState() first.') 23 + } 24 + return globalStateManager 25 + } 26 + 27 + export function resetGlobalMockState(): void { 28 + globalStateManager?.reset() 29 + }
+46
test/e2e/helpers/mock-connector.ts
··· 1 + /** 2 + * E2E mock connector server. Wraps the base server from cli/src/mock-app.ts 3 + * with global singleton state for Playwright test isolation. 4 + */ 5 + 6 + import { MockConnectorServer as BaseMockConnectorServer } from '../../../cli/src/mock-app.ts' 7 + import { type MockConnectorConfig, initGlobalMockState } from './mock-connector-state' 8 + 9 + export class MockConnectorServer { 10 + private baseServer: BaseMockConnectorServer 11 + 12 + constructor(config: MockConnectorConfig) { 13 + const stateManager = initGlobalMockState(config) 14 + this.baseServer = new BaseMockConnectorServer(stateManager) 15 + } 16 + 17 + async start(): Promise<void> { 18 + return this.baseServer.start() 19 + } 20 + 21 + async stop(): Promise<void> { 22 + return this.baseServer.stop() 23 + } 24 + 25 + get state() { 26 + return this.baseServer.state 27 + } 28 + 29 + get port(): number { 30 + return this.baseServer.port 31 + } 32 + 33 + get token(): string { 34 + return this.baseServer.token 35 + } 36 + 37 + reset(): void { 38 + this.baseServer.reset() 39 + } 40 + } 41 + 42 + export { 43 + getGlobalMockState, 44 + resetGlobalMockState, 45 + DEFAULT_MOCK_CONFIG, 46 + } from './mock-connector-state'
+4
test/fixtures/npm-registry/orgs/testorg.json
··· 1 + { 2 + "@testorg/package-a": "write", 3 + "@testorg/package-b": "write" 4 + }
+310
test/nuxt/components/HeaderConnectorModal.spec.ts
··· 1 + import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' 2 + import { mountSuspended } from '@nuxt/test-utils/runtime' 3 + import { ref, computed, readonly, nextTick } from 'vue' 4 + import type { VueWrapper } from '@vue/test-utils' 5 + import type { PendingOperation } from '../../../cli/src/types' 6 + import { HeaderConnectorModal } from '#components' 7 + 8 + // Mock state that will be controlled by tests 9 + const mockState = ref({ 10 + connected: false, 11 + connecting: false, 12 + npmUser: null as string | null, 13 + avatar: null as string | null, 14 + operations: [] as PendingOperation[], 15 + error: null as string | null, 16 + lastExecutionTime: null as number | null, 17 + }) 18 + 19 + // Create the mock composable function 20 + function createMockUseConnector() { 21 + return { 22 + state: readonly(mockState), 23 + isConnected: computed(() => mockState.value.connected), 24 + isConnecting: computed(() => mockState.value.connecting), 25 + npmUser: computed(() => mockState.value.npmUser), 26 + avatar: computed(() => mockState.value.avatar), 27 + error: computed(() => mockState.value.error), 28 + lastExecutionTime: computed(() => mockState.value.lastExecutionTime), 29 + operations: computed(() => mockState.value.operations), 30 + pendingOperations: computed(() => 31 + mockState.value.operations.filter(op => op.status === 'pending'), 32 + ), 33 + approvedOperations: computed(() => 34 + mockState.value.operations.filter(op => op.status === 'approved'), 35 + ), 36 + completedOperations: computed(() => 37 + mockState.value.operations.filter( 38 + op => op.status === 'completed' || (op.status === 'failed' && !op.result?.requiresOtp), 39 + ), 40 + ), 41 + activeOperations: computed(() => 42 + mockState.value.operations.filter( 43 + op => 44 + op.status === 'pending' || 45 + op.status === 'approved' || 46 + op.status === 'running' || 47 + (op.status === 'failed' && op.result?.requiresOtp), 48 + ), 49 + ), 50 + hasOperations: computed(() => mockState.value.operations.length > 0), 51 + hasPendingOperations: computed(() => 52 + mockState.value.operations.some(op => op.status === 'pending'), 53 + ), 54 + hasApprovedOperations: computed(() => 55 + mockState.value.operations.some(op => op.status === 'approved'), 56 + ), 57 + hasActiveOperations: computed(() => 58 + mockState.value.operations.some( 59 + op => 60 + op.status === 'pending' || 61 + op.status === 'approved' || 62 + op.status === 'running' || 63 + (op.status === 'failed' && op.result?.requiresOtp), 64 + ), 65 + ), 66 + hasCompletedOperations: computed(() => 67 + mockState.value.operations.some( 68 + op => op.status === 'completed' || (op.status === 'failed' && !op.result?.requiresOtp), 69 + ), 70 + ), 71 + connect: vi.fn().mockResolvedValue(true), 72 + reconnect: vi.fn().mockResolvedValue(true), 73 + disconnect: vi.fn(), 74 + refreshState: vi.fn().mockResolvedValue(undefined), 75 + addOperation: vi.fn().mockResolvedValue(null), 76 + addOperations: vi.fn().mockResolvedValue([]), 77 + removeOperation: vi.fn().mockResolvedValue(true), 78 + clearOperations: vi.fn().mockResolvedValue(0), 79 + approveOperation: vi.fn().mockResolvedValue(true), 80 + retryOperation: vi.fn().mockResolvedValue(true), 81 + approveAll: vi.fn().mockResolvedValue(0), 82 + executeOperations: vi.fn().mockResolvedValue({ success: true }), 83 + listOrgUsers: vi.fn().mockResolvedValue(null), 84 + listOrgTeams: vi.fn().mockResolvedValue(null), 85 + listTeamUsers: vi.fn().mockResolvedValue(null), 86 + listPackageCollaborators: vi.fn().mockResolvedValue(null), 87 + listUserPackages: vi.fn().mockResolvedValue(null), 88 + listUserOrgs: vi.fn().mockResolvedValue(null), 89 + } 90 + } 91 + 92 + function resetMockState() { 93 + mockState.value = { 94 + connected: false, 95 + connecting: false, 96 + npmUser: null, 97 + avatar: null, 98 + operations: [], 99 + error: null, 100 + lastExecutionTime: null, 101 + } 102 + } 103 + 104 + function simulateConnect() { 105 + mockState.value.connected = true 106 + mockState.value.npmUser = 'testuser' 107 + mockState.value.avatar = 'https://example.com/avatar.png' 108 + } 109 + 110 + // Mock the composables at module level (vi.mock is hoisted) 111 + vi.mock('~/composables/useConnector', () => ({ 112 + useConnector: createMockUseConnector, 113 + })) 114 + 115 + vi.mock('~/composables/useSelectedPackageManager', () => ({ 116 + useSelectedPackageManager: () => ref('npm'), 117 + })) 118 + 119 + vi.mock('~/utils/npm', () => ({ 120 + getExecuteCommand: () => 'npx npmx-connector', 121 + })) 122 + 123 + // Mock clipboard 124 + const mockWriteText = vi.fn().mockResolvedValue(undefined) 125 + vi.stubGlobal('navigator', { 126 + ...navigator, 127 + clipboard: { 128 + writeText: mockWriteText, 129 + readText: vi.fn().mockResolvedValue(''), 130 + }, 131 + }) 132 + 133 + // Track current wrapper for cleanup 134 + let currentWrapper: VueWrapper | null = null 135 + 136 + /** 137 + * Get the modal dialog element from the document body (where Teleport sends it). 138 + */ 139 + function getModalDialog(): HTMLDialogElement | null { 140 + return document.body.querySelector('dialog#connector-modal') 141 + } 142 + 143 + /** 144 + * Mount the component and open the dialog via showModal(). 145 + */ 146 + async function mountAndOpen(state?: 'connected' | 'error') { 147 + if (state === 'connected') simulateConnect() 148 + if (state === 'error') { 149 + mockState.value.error = 'Could not reach connector. Is it running?' 150 + } 151 + 152 + currentWrapper = await mountSuspended(HeaderConnectorModal, { 153 + attachTo: document.body, 154 + }) 155 + await nextTick() 156 + 157 + const dialog = getModalDialog() 158 + dialog?.showModal() 159 + await nextTick() 160 + 161 + return dialog 162 + } 163 + 164 + // Reset state before each test 165 + beforeEach(() => { 166 + resetMockState() 167 + mockWriteText.mockClear() 168 + }) 169 + 170 + afterEach(() => { 171 + vi.clearAllMocks() 172 + if (currentWrapper) { 173 + currentWrapper.unmount() 174 + currentWrapper = null 175 + } 176 + }) 177 + 178 + describe('HeaderConnectorModal', () => { 179 + describe('Disconnected state', () => { 180 + it('shows connection form when not connected', async () => { 181 + const dialog = await mountAndOpen() 182 + expect(dialog).not.toBeNull() 183 + 184 + // Should show the form (disconnected state) 185 + const form = dialog?.querySelector('form') 186 + expect(form).not.toBeNull() 187 + 188 + // Should show token input 189 + const tokenInput = dialog?.querySelector('input[name="connector-token"]') 190 + expect(tokenInput).not.toBeNull() 191 + 192 + // Should show connect button 193 + const connectButton = dialog?.querySelector('button[type="submit"]') 194 + expect(connectButton).not.toBeNull() 195 + }) 196 + 197 + it('shows the CLI command to run', async () => { 198 + const dialog = await mountAndOpen() 199 + // The command is now "pnpm npmx-connector" 200 + expect(dialog?.textContent).toContain('npmx-connector') 201 + }) 202 + 203 + it('has a copy button for the command', async () => { 204 + const dialog = await mountAndOpen() 205 + // The copy button is inside the command block (dir="ltr" div) 206 + const commandBlock = dialog?.querySelector('div[dir="ltr"]') 207 + const copyBtn = commandBlock?.querySelector('button') as HTMLButtonElement 208 + expect(copyBtn).toBeTruthy() 209 + // The button should have a copy-related aria-label 210 + expect(copyBtn?.getAttribute('aria-label')).toBeTruthy() 211 + }) 212 + 213 + it('disables connect button when token is empty', async () => { 214 + const dialog = await mountAndOpen() 215 + const connectButton = dialog?.querySelector('button[type="submit"]') as HTMLButtonElement 216 + expect(connectButton?.disabled).toBe(true) 217 + }) 218 + 219 + it('enables connect button when token is entered', async () => { 220 + const dialog = await mountAndOpen() 221 + const tokenInput = dialog?.querySelector('input[name="connector-token"]') as HTMLInputElement 222 + expect(tokenInput).not.toBeNull() 223 + 224 + // Set value and dispatch input event to trigger v-model 225 + tokenInput.value = 'my-test-token' 226 + tokenInput.dispatchEvent(new Event('input', { bubbles: true })) 227 + await nextTick() 228 + 229 + const connectButton = dialog?.querySelector('button[type="submit"]') as HTMLButtonElement 230 + expect(connectButton?.disabled).toBe(false) 231 + }) 232 + 233 + it('shows error message when connection fails', async () => { 234 + const dialog = await mountAndOpen('error') 235 + // Error needs hasAttemptedConnect=true to show. Simulate a connect attempt first. 236 + const tokenInput = dialog?.querySelector('input[name="connector-token"]') as HTMLInputElement 237 + tokenInput.value = 'bad-token' 238 + tokenInput.dispatchEvent(new Event('input', { bubbles: true })) 239 + await nextTick() 240 + 241 + const form = dialog?.querySelector('form') 242 + form?.dispatchEvent(new Event('submit', { bubbles: true })) 243 + await nextTick() 244 + 245 + const alerts = dialog?.querySelectorAll('[role="alert"]') 246 + const errorAlert = Array.from(alerts || []).find(el => 247 + el.textContent?.includes('Could not reach connector'), 248 + ) 249 + expect(errorAlert).not.toBeUndefined() 250 + }) 251 + }) 252 + 253 + describe('Connected state', () => { 254 + it('shows connected status', async () => { 255 + const dialog = await mountAndOpen('connected') 256 + expect(dialog?.textContent).toContain('Connected') 257 + }) 258 + 259 + it('shows logged in username', async () => { 260 + const dialog = await mountAndOpen('connected') 261 + expect(dialog?.textContent).toContain('testuser') 262 + }) 263 + 264 + it('shows disconnect button', async () => { 265 + const dialog = await mountAndOpen('connected') 266 + const buttons = dialog?.querySelectorAll('button') 267 + const disconnectBtn = Array.from(buttons || []).find(b => 268 + b.textContent?.toLowerCase().includes('disconnect'), 269 + ) 270 + expect(disconnectBtn).not.toBeUndefined() 271 + }) 272 + 273 + it('hides connection form when connected', async () => { 274 + const dialog = await mountAndOpen('connected') 275 + const form = dialog?.querySelector('form') 276 + expect(form).toBeNull() 277 + }) 278 + }) 279 + 280 + describe('Modal behavior', () => { 281 + it('closes modal when close button is clicked', async () => { 282 + const dialog = await mountAndOpen() 283 + 284 + // Find the close button (ButtonBase with close icon) in the dialog header 285 + const closeBtn = Array.from(dialog?.querySelectorAll('button') ?? []).find( 286 + b => 287 + b.querySelector('[class*="close"]') || 288 + b.getAttribute('aria-label')?.toLowerCase().includes('close'), 289 + ) as HTMLButtonElement 290 + expect(closeBtn).toBeTruthy() 291 + 292 + closeBtn?.click() 293 + await nextTick() 294 + 295 + // Dialog should be closed (open attribute removed) 296 + expect(dialog?.open).toBe(false) 297 + }) 298 + 299 + it('does not render dialog content when not opened', async () => { 300 + currentWrapper = await mountSuspended(HeaderConnectorModal, { 301 + attachTo: document.body, 302 + }) 303 + await nextTick() 304 + 305 + const dialog = getModalDialog() 306 + // Dialog exists in DOM but should not be open 307 + expect(dialog?.open).toBeFalsy() 308 + }) 309 + }) 310 + })