forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1/**
2 * Mock connector state management. Canonical source used by the mock server,
3 * E2E tests, and Vitest composable mocks.
4 */
5
6import type {
7 PendingOperation,
8 OperationType,
9 OperationResult,
10 OrgRole,
11 AccessPermission,
12} from './types.ts'
13
14export interface MockConnectorConfig {
15 token: string
16 npmUser: string
17 avatar?: string | null
18 port?: number
19}
20
21export interface MockOrgData {
22 users: Record<string, OrgRole>
23 teams: string[]
24 /** team name -> member usernames */
25 teamMembers: Record<string, string[]>
26}
27
28export interface MockPackageData {
29 collaborators: Record<string, AccessPermission>
30}
31
32export 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
44export interface NewOperationInput {
45 type: OperationType
46 params: Record<string, string>
47 description: string
48 command: string
49 dependsOn?: string
50}
51
52export interface ExecuteOptions {
53 otp?: string
54 /** Per-operation results for testing failures. */
55 results?: Record<string, Partial<OperationResult>>
56}
57
58export interface ExecuteResult {
59 results: Array<{ id: string; result: OperationResult }>
60 otpRequired?: boolean
61 authFailure?: boolean
62 urls?: string[]
63}
64
65export function createMockConnectorState(config: MockConnectorConfig): MockConnectorStateData {
66 return {
67 config: {
68 port: 31415,
69 avatar: null,
70 ...config,
71 },
72 connected: false,
73 connectedAt: null,
74 orgs: {},
75 packages: {},
76 userPackages: {},
77 userOrgs: [],
78 operations: [],
79 operationIdCounter: 0,
80 }
81}
82
83/**
84 * Mock connector state, shared between the HTTP server and composable mock.
85 */
86export class MockConnectorStateManager {
87 public state: MockConnectorStateData
88
89 constructor(initialState: MockConnectorStateData) {
90 this.state = initialState
91 }
92
93 // -- Configuration --
94
95 get config(): MockConnectorConfig {
96 return this.state.config
97 }
98
99 get token(): string {
100 return this.state.config.token
101 }
102
103 get port(): number {
104 return this.state.config.port ?? 31415
105 }
106
107 // -- Connection --
108
109 connect(token: string): boolean {
110 if (token !== this.state.config.token) {
111 return false
112 }
113 this.state.connected = true
114 this.state.connectedAt = Date.now()
115 return true
116 }
117
118 disconnect(): void {
119 this.state.connected = false
120 this.state.connectedAt = null
121 this.state.operations = []
122 }
123
124 isConnected(): boolean {
125 return this.state.connected
126 }
127
128 // -- Org data --
129
130 setOrgData(org: string, data: Partial<MockOrgData>): void {
131 const existing = this.state.orgs[org] ?? { users: {}, teams: [], teamMembers: {} }
132 this.state.orgs[org] = {
133 users: { ...existing.users, ...data.users },
134 teams: data.teams ?? existing.teams,
135 teamMembers: { ...existing.teamMembers, ...data.teamMembers },
136 }
137 }
138
139 getOrgUsers(org: string): Record<string, OrgRole> | null {
140 const normalizedOrg = org.startsWith('@') ? org : `@${org}`
141 return this.state.orgs[normalizedOrg]?.users ?? null
142 }
143
144 getOrgTeams(org: string): string[] | null {
145 const normalizedOrg = org.startsWith('@') ? org : `@${org}`
146 return this.state.orgs[normalizedOrg]?.teams ?? null
147 }
148
149 getTeamUsers(scope: string, team: string): string[] | null {
150 const normalizedScope = scope.startsWith('@') ? scope : `@${scope}`
151 const org = this.state.orgs[normalizedScope]
152 if (!org) return null
153 return org.teamMembers[team] ?? null
154 }
155
156 // -- Package data --
157
158 setPackageData(pkg: string, data: MockPackageData): void {
159 this.state.packages[pkg] = data
160 }
161
162 getPackageCollaborators(pkg: string): Record<string, AccessPermission> | null {
163 return this.state.packages[pkg]?.collaborators ?? null
164 }
165
166 // -- User data --
167
168 setUserPackages(packages: Record<string, AccessPermission>): void {
169 this.state.userPackages = packages
170 }
171
172 setUserOrgs(orgs: string[]): void {
173 this.state.userOrgs = orgs
174 }
175
176 getUserPackages(): Record<string, AccessPermission> {
177 return this.state.userPackages
178 }
179
180 getUserOrgs(): string[] {
181 return this.state.userOrgs
182 }
183
184 // -- Operations queue --
185
186 addOperation(operation: NewOperationInput): PendingOperation {
187 const id = `op-${++this.state.operationIdCounter}`
188 const newOp: PendingOperation = {
189 id,
190 type: operation.type,
191 params: operation.params,
192 description: operation.description,
193 command: operation.command,
194 status: 'pending',
195 createdAt: Date.now(),
196 dependsOn: operation.dependsOn,
197 }
198 this.state.operations.push(newOp)
199 return newOp
200 }
201
202 addOperations(operations: NewOperationInput[]): PendingOperation[] {
203 return operations.map(op => this.addOperation(op))
204 }
205
206 getOperation(id: string): PendingOperation | undefined {
207 return this.state.operations.find(op => op.id === id)
208 }
209
210 getOperations(): PendingOperation[] {
211 return this.state.operations
212 }
213
214 removeOperation(id: string): boolean {
215 const index = this.state.operations.findIndex(op => op.id === id)
216 if (index === -1) return false
217 const op = this.state.operations[index]
218 // Can't remove running operations
219 if (op?.status === 'running') return false
220 this.state.operations.splice(index, 1)
221 return true
222 }
223
224 clearOperations(): number {
225 const removable = this.state.operations.filter(op => op.status !== 'running')
226 const count = removable.length
227 this.state.operations = this.state.operations.filter(op => op.status === 'running')
228 return count
229 }
230
231 approveOperation(id: string): PendingOperation | null {
232 const op = this.state.operations.find(op => op.id === id)
233 if (!op || op.status !== 'pending') return null
234 op.status = 'approved'
235 return op
236 }
237
238 approveAll(): number {
239 let count = 0
240 for (const op of this.state.operations) {
241 if (op.status === 'pending') {
242 op.status = 'approved'
243 count++
244 }
245 }
246 return count
247 }
248
249 retryOperation(id: string): PendingOperation | null {
250 const op = this.state.operations.find(op => op.id === id)
251 if (!op || op.status !== 'failed') return null
252 op.status = 'approved'
253 op.result = undefined
254 return op
255 }
256
257 /** Execute all approved operations (mock: instant success unless configured otherwise). */
258 executeOperations(options?: ExecuteOptions): ExecuteResult {
259 const results: Array<{ id: string; result: OperationResult }> = []
260 const approved = this.state.operations.filter(op => op.status === 'approved')
261
262 // Sort by dependencies
263 const sorted = this.sortByDependencies(approved)
264
265 for (const op of sorted) {
266 // Check if dependent operation completed successfully
267 if (op.dependsOn) {
268 const dep = this.state.operations.find(d => d.id === op.dependsOn)
269 if (!dep || dep.status !== 'completed') {
270 // Skip - dependency not met
271 continue
272 }
273 }
274
275 op.status = 'running'
276
277 // Check for configured result
278 const configuredResult = options?.results?.[op.id]
279 if (configuredResult) {
280 const result: OperationResult = {
281 stdout: configuredResult.stdout ?? '',
282 stderr: configuredResult.stderr ?? '',
283 exitCode: configuredResult.exitCode ?? 1,
284 requiresOtp: configuredResult.requiresOtp,
285 authFailure: configuredResult.authFailure,
286 urls: configuredResult.urls,
287 }
288 op.result = result
289 op.status = result.exitCode === 0 ? 'completed' : 'failed'
290 results.push({ id: op.id, result })
291
292 if (result.requiresOtp && !options?.otp) {
293 return { results, otpRequired: true }
294 }
295 } else {
296 // Default: success
297 const result: OperationResult = {
298 stdout: `Mock: ${op.command}`,
299 stderr: '',
300 exitCode: 0,
301 }
302 op.result = result
303 op.status = 'completed'
304 results.push({ id: op.id, result })
305
306 // Apply the operation's effects to mock state
307 this.applyOperationEffect(op)
308 }
309 }
310
311 const authFailure = results.some(r => r.result.authFailure)
312 const allUrls = results.flatMap(r => r.result.urls ?? [])
313 const urls = [...new Set(allUrls)]
314
315 return {
316 results,
317 authFailure: authFailure || undefined,
318 urls: urls.length > 0 ? urls : undefined,
319 }
320 }
321
322 /** Apply side effects of a completed operation. Param keys match schemas.ts. */
323 private applyOperationEffect(op: PendingOperation): void {
324 const { type, params } = op
325
326 switch (type) {
327 case 'org:add-user': {
328 // Params: { org, user, role } — OrgAddUserParamsSchema
329 const org = params['org']
330 const user = params['user']
331 const role = (params['role'] as OrgRole) ?? 'developer'
332 if (org && user) {
333 const normalizedOrg = org.startsWith('@') ? org : `@${org}`
334 if (!this.state.orgs[normalizedOrg]) {
335 this.state.orgs[normalizedOrg] = { users: {}, teams: [], teamMembers: {} }
336 }
337 this.state.orgs[normalizedOrg].users[user] = role
338 }
339 break
340 }
341 case 'org:rm-user': {
342 // Params: { org, user } — OrgRemoveUserParamsSchema
343 const org = params['org']
344 const user = params['user']
345 if (org && user) {
346 const normalizedOrg = org.startsWith('@') ? org : `@${org}`
347 if (this.state.orgs[normalizedOrg]) {
348 delete this.state.orgs[normalizedOrg].users[user]
349 }
350 }
351 break
352 }
353 case 'org:set-role': {
354 // Params: { org, user, role } — reuses OrgAddUserParamsSchema
355 const org = params['org']
356 const user = params['user']
357 const role = params['role'] as OrgRole
358 if (org && user && role) {
359 const normalizedOrg = org.startsWith('@') ? org : `@${org}`
360 if (this.state.orgs[normalizedOrg]) {
361 this.state.orgs[normalizedOrg].users[user] = role
362 }
363 }
364 break
365 }
366 case 'team:create': {
367 // Params: { scopeTeam } — TeamCreateParamsSchema
368 const scopeTeam = params['scopeTeam']
369 if (scopeTeam) {
370 const [scope, team] = scopeTeam.split(':')
371 if (scope && team) {
372 const normalizedScope = scope.startsWith('@') ? scope : `@${scope}`
373 if (!this.state.orgs[normalizedScope]) {
374 this.state.orgs[normalizedScope] = { users: {}, teams: [], teamMembers: {} }
375 }
376 if (!this.state.orgs[normalizedScope].teams.includes(team)) {
377 this.state.orgs[normalizedScope].teams.push(team)
378 }
379 this.state.orgs[normalizedScope].teamMembers[team] = []
380 }
381 }
382 break
383 }
384 case 'team:destroy': {
385 // Params: { scopeTeam } — TeamDestroyParamsSchema
386 const scopeTeam = params['scopeTeam']
387 if (scopeTeam) {
388 const [scope, team] = scopeTeam.split(':')
389 if (scope && team) {
390 const normalizedScope = scope.startsWith('@') ? scope : `@${scope}`
391 if (this.state.orgs[normalizedScope]) {
392 this.state.orgs[normalizedScope].teams = this.state.orgs[
393 normalizedScope
394 ].teams.filter(t => t !== team)
395 delete this.state.orgs[normalizedScope].teamMembers[team]
396 }
397 }
398 }
399 break
400 }
401 case 'team:add-user': {
402 // Params: { scopeTeam, user } — TeamAddUserParamsSchema
403 const scopeTeam = params['scopeTeam']
404 const user = params['user']
405 if (scopeTeam && user) {
406 const [scope, team] = scopeTeam.split(':')
407 if (scope && team) {
408 const normalizedScope = scope.startsWith('@') ? scope : `@${scope}`
409 if (this.state.orgs[normalizedScope]) {
410 const members = this.state.orgs[normalizedScope].teamMembers[team] ?? []
411 if (!members.includes(user)) {
412 members.push(user)
413 }
414 this.state.orgs[normalizedScope].teamMembers[team] = members
415 }
416 }
417 }
418 break
419 }
420 case 'team:rm-user': {
421 // Params: { scopeTeam, user } — TeamRemoveUserParamsSchema
422 const scopeTeam = params['scopeTeam']
423 const user = params['user']
424 if (scopeTeam && user) {
425 const [scope, team] = scopeTeam.split(':')
426 if (scope && team) {
427 const normalizedScope = scope.startsWith('@') ? scope : `@${scope}`
428 if (this.state.orgs[normalizedScope]) {
429 const members = this.state.orgs[normalizedScope].teamMembers[team]
430 if (members) {
431 this.state.orgs[normalizedScope].teamMembers[team] = members.filter(u => u !== user)
432 }
433 }
434 }
435 }
436 break
437 }
438 case 'access:grant': {
439 // Params: { permission, scopeTeam, pkg } — AccessGrantParamsSchema
440 const pkg = params['pkg']
441 const scopeTeam = params['scopeTeam']
442 const permission = (params['permission'] as AccessPermission) ?? 'read-write'
443 if (pkg && scopeTeam) {
444 if (!this.state.packages[pkg]) {
445 this.state.packages[pkg] = { collaborators: {} }
446 }
447 this.state.packages[pkg].collaborators[scopeTeam] = permission
448 }
449 break
450 }
451 case 'access:revoke': {
452 // Params: { scopeTeam, pkg } — AccessRevokeParamsSchema
453 const pkg = params['pkg']
454 const scopeTeam = params['scopeTeam']
455 if (pkg && scopeTeam && this.state.packages[pkg]) {
456 delete this.state.packages[pkg].collaborators[scopeTeam]
457 }
458 break
459 }
460 case 'owner:add': {
461 // Params: { user, pkg } — OwnerAddParamsSchema
462 const pkg = params['pkg']
463 const user = params['user']
464 if (pkg && user) {
465 if (!this.state.packages[pkg]) {
466 this.state.packages[pkg] = { collaborators: {} }
467 }
468 this.state.packages[pkg].collaborators[user] = 'read-write'
469 }
470 break
471 }
472 case 'owner:rm': {
473 // Params: { user, pkg } — OwnerRemoveParamsSchema
474 const pkg = params['pkg']
475 const user = params['user']
476 if (pkg && user && this.state.packages[pkg]) {
477 delete this.state.packages[pkg].collaborators[user]
478 }
479 break
480 }
481 case 'package:init': {
482 // Params: { name, author? } — PackageInitParamsSchema
483 const name = params['name']
484 if (name) {
485 this.state.packages[name] = {
486 collaborators: { [this.state.config.npmUser]: 'read-write' },
487 }
488 this.state.userPackages[name] = 'read-write'
489 }
490 break
491 }
492 }
493 }
494
495 /** Topological sort by dependsOn. */
496 private sortByDependencies(operations: PendingOperation[]): PendingOperation[] {
497 const result: PendingOperation[] = []
498 const visited = new Set<string>()
499
500 const visit = (op: PendingOperation) => {
501 if (visited.has(op.id)) return
502 visited.add(op.id)
503
504 if (op.dependsOn) {
505 const dep = operations.find(d => d.id === op.dependsOn)
506 if (dep) visit(dep)
507 }
508
509 result.push(op)
510 }
511
512 for (const op of operations) {
513 visit(op)
514 }
515
516 return result
517 }
518
519 reset(): void {
520 this.state.connected = false
521 this.state.connectedAt = null
522 this.state.orgs = {}
523 this.state.packages = {}
524 this.state.userPackages = {}
525 this.state.userOrgs = []
526 this.state.operations = []
527 this.state.operationIdCounter = 0
528 }
529}
530
531/** @internal */
532export const DEFAULT_MOCK_CONFIG: MockConnectorConfig = {
533 token: 'test-token-e2e-12345',
534 npmUser: 'testuser',
535 avatar: null,
536 port: 31415,
537}