@camstack/server 0.1.6 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/addon-upload.spec.ts +58 -0
  3. package/src/__tests__/bulk-update-coordinator.spec.ts +286 -0
  4. package/src/__tests__/cap-ownership-authority.spec.ts +400 -0
  5. package/src/__tests__/cap-providers-bulk-update.spec.ts +388 -0
  6. package/src/__tests__/cap-route-adapter.spec.ts +289 -0
  7. package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +123 -0
  8. package/src/__tests__/cap-routers/capabilities-node.spec.ts +55 -0
  9. package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +30 -0
  10. package/src/__tests__/device-settings-contribution-dispatch.spec.ts +249 -0
  11. package/src/__tests__/framework-installer-defer-restart.spec.ts +165 -0
  12. package/src/__tests__/moleculer/uds-readiness.spec.ts +143 -0
  13. package/src/__tests__/moleculer/uds-topology.spec.ts +390 -0
  14. package/src/__tests__/moleculer/uds-unowned-call.spec.ts +123 -0
  15. package/src/__tests__/moleculer-register-node-idempotency.spec.ts +39 -4
  16. package/src/__tests__/native-cap-route.spec.ts +404 -0
  17. package/src/__tests__/oauth2-account-linking.spec.ts +85 -0
  18. package/src/__tests__/uds-addon-call-wiring.spec.ts +237 -0
  19. package/src/__tests__/uds-log-ingest.spec.ts +183 -0
  20. package/src/api/addon-upload.ts +27 -1
  21. package/src/api/capabilities.router.ts +1 -1
  22. package/src/api/core/bulk-update-coordinator.ts +302 -0
  23. package/src/api/core/cap-providers.ts +59 -6
  24. package/src/api/core/capabilities.router.ts +26 -3
  25. package/src/api/oauth2/oauth2-routes.ts +5 -1
  26. package/src/api/trpc/__tests__/client-ip.spec.ts +120 -0
  27. package/src/api/trpc/cap-route-error-formatter.ts +163 -0
  28. package/src/api/trpc/client-ip.ts +130 -0
  29. package/src/api/trpc/generated-cap-mounts.ts +19 -1
  30. package/src/api/trpc/generated-cap-routers.ts +180 -1
  31. package/src/api/trpc/trpc.middleware.ts +5 -1
  32. package/src/api/trpc/trpc.router.ts +45 -0
  33. package/src/core/addon/addon-call-gateway.ts +157 -0
  34. package/src/core/addon/addon-package.service.ts +9 -0
  35. package/src/core/addon/addon-registry.service.ts +364 -105
  36. package/src/core/addon/addon-settings-provider.ts +40 -116
  37. package/src/core/capability/capability.service.ts +9 -0
  38. package/src/core/moleculer/cap-call-fn.spec.ts +166 -0
  39. package/src/core/moleculer/cap-call-fn.ts +103 -0
  40. package/src/core/moleculer/cap-route-authority.ts +182 -0
  41. package/src/core/moleculer/moleculer.service.ts +380 -36
  42. package/src/main.ts +45 -12
@@ -0,0 +1,388 @@
1
+ /**
2
+ * cap-providers-bulk-update.spec.ts
3
+ *
4
+ * Verifies the wire-up in buildAddonsProvider for:
5
+ * 1. Delegation to BulkUpdateCoordinator (startBulkUpdate / cancelBulkUpdate /
6
+ * getBulkUpdateState / listActiveBulkUpdates)
7
+ * 2. isSystem field added to listUpdates output
8
+ * 3. deferRestart propagated through updateFrameworkPackage
9
+ *
10
+ * Spec: docs/superpowers/specs/2026-05-21-addons-bulk-update-progress-design.md
11
+ * Plan: docs/superpowers/plans/2026-05-21-addons-bulk-update-progress.md (Task 4)
12
+ */
13
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
14
+ import type { IAddonsProvider } from '@camstack/types'
15
+ import { FRAMEWORK_PACKAGE_ALLOWLIST } from '../core/addon/addon-package.service.js'
16
+
17
+ // ── Module-level mock ─────────────────────────────────────────────────────────
18
+ // Must be hoisted before any import that resolves bulk-update-coordinator.
19
+ // Uses a real class so `new BulkUpdateCoordinator(...)` works; the instance
20
+ // methods are vi.fn stubs shared across all instances created in a test.
21
+ const _startFn = vi.fn()
22
+ const _getFn = vi.fn()
23
+ const _cancelFn = vi.fn()
24
+ const _listFn = vi.fn()
25
+
26
+ vi.mock('../api/core/bulk-update-coordinator.js', () => {
27
+ class MockBulkUpdateCoordinator {
28
+ start = _startFn
29
+ get = _getFn
30
+ cancel = _cancelFn
31
+ list = _listFn
32
+ }
33
+ return { BulkUpdateCoordinator: MockBulkUpdateCoordinator }
34
+ })
35
+
36
+ // ── Imports that depend on the mocked modules ─────────────────────────────────
37
+ import { buildAddonsProvider } from '../api/core/cap-providers.js'
38
+ import { makeCtx } from './cap-routers/harness.js'
39
+
40
+ // ── Helpers ───────────────────────────────────────────────────────────────────
41
+
42
+ function makeLogger() {
43
+ const logger = {
44
+ info: vi.fn(),
45
+ warn: vi.fn(),
46
+ error: vi.fn(),
47
+ debug: vi.fn(),
48
+ trace: vi.fn(),
49
+ fatal: vi.fn(),
50
+ scope: vi.fn(),
51
+ child: vi.fn(),
52
+ }
53
+ // scope returns a scoped logger with the same shape
54
+ logger.scope.mockReturnValue(logger)
55
+ logger.child.mockReturnValue(logger)
56
+ return logger
57
+ }
58
+
59
+ type StubStash = {
60
+ start: ReturnType<typeof vi.fn>
61
+ get: ReturnType<typeof vi.fn>
62
+ cancel: ReturnType<typeof vi.fn>
63
+ list: ReturnType<typeof vi.fn>
64
+ }
65
+
66
+ function getCoordinatorStubs(): StubStash {
67
+ // The module-level fn references _startFn etc., shared across all mock instances.
68
+ return {
69
+ start: _startFn,
70
+ get: _getFn,
71
+ cancel: _cancelFn,
72
+ list: _listFn,
73
+ }
74
+ }
75
+
76
+ interface ProviderEnv {
77
+ readonly provider: IAddonsProvider
78
+ readonly psMock: Record<string, ReturnType<typeof vi.fn>>
79
+ }
80
+
81
+ function createProviderEnv(): ProviderEnv {
82
+ const psMock: Record<string, ReturnType<typeof vi.fn>> = {
83
+ getRollbackablePackages: vi.fn().mockReturnValue(new Set()),
84
+ getAddonHealthSnapshot: vi.fn().mockReturnValue([]),
85
+ listAllAddons: vi.fn().mockReturnValue([]),
86
+ listInstalled: vi.fn().mockReturnValue([]),
87
+ installAndLoad: vi.fn().mockResolvedValue({ success: true }),
88
+ installFromWorkspaceAndLoad: vi.fn().mockResolvedValue({ success: true }),
89
+ isWorkspaceAvailable: vi.fn().mockResolvedValue(false),
90
+ listWorkspacePackages: vi.fn().mockResolvedValue([]),
91
+ uninstallAndReload: vi.fn().mockResolvedValue({ success: true }),
92
+ reloadPackages: vi.fn().mockResolvedValue({ success: true }),
93
+ searchNpm: vi.fn().mockResolvedValue([]),
94
+ checkUpdates: vi.fn().mockResolvedValue([]),
95
+ checkUpdatesForInstalled: vi.fn().mockResolvedValue([]),
96
+ updatePackage: vi.fn().mockResolvedValue({ success: true }),
97
+ rollbackPackage: vi.fn().mockResolvedValue({ success: true }),
98
+ restartServer: vi.fn().mockResolvedValue({ success: true }),
99
+ listFrameworkPackages: vi.fn().mockResolvedValue([]),
100
+ getPackageVersions: vi.fn().mockResolvedValue([]),
101
+ getAutoUpdateSettings: vi.fn().mockResolvedValue({ channel: 'stable', intervalSeconds: 3600 }),
102
+ setAutoUpdateSettings: vi.fn().mockResolvedValue({ success: true }),
103
+ getAddonAutoUpdate: vi.fn().mockResolvedValue({ channel: 'inherit' }),
104
+ setAddonAutoUpdate: vi.fn().mockResolvedValue({ success: true }),
105
+ updateFrameworkPackage: vi.fn().mockResolvedValue({
106
+ packageName: '@camstack/types',
107
+ fromVersion: '0.1.38',
108
+ toVersion: '0.1.40',
109
+ restartingAt: Date.now() + 500,
110
+ }),
111
+ packPackage: vi.fn().mockResolvedValue({ buffer: Buffer.alloc(0), version: '1.0.0' }),
112
+ }
113
+
114
+ const arMock = {
115
+ listAddons: vi.fn().mockReturnValue([]),
116
+ listAllAddons: vi.fn().mockReturnValue([]),
117
+ getAddonHealthSnapshot: vi.fn().mockReturnValue([]),
118
+ getCapabilityRegistry: vi.fn().mockReturnValue({
119
+ listCapabilities: vi.fn().mockReturnValue([]),
120
+ }),
121
+ getCustomActionRegistry: vi.fn().mockReturnValue({
122
+ resolve: vi.fn().mockReturnValue(null),
123
+ }),
124
+ restartAddon: vi.fn().mockResolvedValue({ success: true }),
125
+ retryAddonLoad: vi.fn().mockResolvedValue({ success: true }),
126
+ }
127
+
128
+ const lsMock = {
129
+ createLogger: vi.fn().mockReturnValue(makeLogger()),
130
+ query: vi.fn().mockResolvedValue([]),
131
+ subscribe: vi.fn().mockReturnValue(() => {}),
132
+ }
133
+
134
+ const moleculerMock = {
135
+ broker: {
136
+ call: vi.fn().mockResolvedValue({}),
137
+ },
138
+ }
139
+
140
+ const configServiceMock = {}
141
+
142
+ const ebMock = {
143
+ emit: vi.fn(),
144
+ subscribe: vi.fn().mockReturnValue(() => {}),
145
+ }
146
+
147
+ const ctx = makeCtx('admin')
148
+
149
+ const provider = buildAddonsProvider(
150
+ arMock as never,
151
+ psMock as never,
152
+ lsMock as never,
153
+ moleculerMock as never,
154
+ configServiceMock as never,
155
+ ctx,
156
+ ebMock as never,
157
+ )
158
+
159
+ return { provider, psMock }
160
+ }
161
+
162
+ // ── Tests ─────────────────────────────────────────────────────────────────────
163
+
164
+ describe('buildAddonsProvider — BulkUpdateCoordinator delegation', () => {
165
+ let env: ProviderEnv
166
+ let stubs: StubStash
167
+
168
+ beforeEach(() => {
169
+ vi.clearAllMocks()
170
+ env = createProviderEnv()
171
+ stubs = getCoordinatorStubs()
172
+ })
173
+
174
+ it('startBulkUpdate delegates to coordinator.start with same args and returns its value', async () => {
175
+ const returnValue = { id: 'bulk-abc-123' }
176
+ stubs.start.mockReturnValue(returnValue)
177
+
178
+ const input = {
179
+ nodeId: 'hub',
180
+ items: [
181
+ { name: '@camstack/addon-stream-broker', version: '1.2.3', isSystem: false },
182
+ { name: '@camstack/types', version: '0.1.40', isSystem: true },
183
+ ] as const,
184
+ }
185
+
186
+ const result = await env.provider.startBulkUpdate(input)
187
+
188
+ expect(stubs.start).toHaveBeenCalledOnce()
189
+ expect(stubs.start).toHaveBeenCalledWith(input)
190
+ expect(result).toEqual(returnValue)
191
+ })
192
+
193
+ it('cancelBulkUpdate delegates to coordinator.cancel with the id and returns its value', async () => {
194
+ const returnValue = { cancelled: true }
195
+ stubs.cancel.mockReturnValue(returnValue)
196
+
197
+ const result = await env.provider.cancelBulkUpdate({ id: 'bulk-xyz' })
198
+
199
+ expect(stubs.cancel).toHaveBeenCalledOnce()
200
+ expect(stubs.cancel).toHaveBeenCalledWith('bulk-xyz')
201
+ expect(result).toEqual(returnValue)
202
+ })
203
+
204
+ it('getBulkUpdateState delegates to coordinator.get with the id and returns its value', async () => {
205
+ const mockState = {
206
+ id: 'bulk-abc',
207
+ nodeId: 'hub',
208
+ startedAtMs: 1000,
209
+ total: 2,
210
+ completed: 1,
211
+ failed: 0,
212
+ current: null,
213
+ phase: 'regular' as const,
214
+ cancelled: false,
215
+ items: [],
216
+ }
217
+ stubs.get.mockReturnValue(mockState)
218
+
219
+ const result = await env.provider.getBulkUpdateState({ id: 'bulk-abc' })
220
+
221
+ expect(stubs.get).toHaveBeenCalledOnce()
222
+ expect(stubs.get).toHaveBeenCalledWith('bulk-abc')
223
+ expect(result).toEqual(mockState)
224
+ })
225
+
226
+ it('getBulkUpdateState returns null when coordinator.get returns null', async () => {
227
+ stubs.get.mockReturnValue(null)
228
+
229
+ const result = await env.provider.getBulkUpdateState({ id: 'unknown-id' })
230
+
231
+ expect(result).toBeNull()
232
+ })
233
+
234
+ it('listActiveBulkUpdates delegates to coordinator.list with nodeId and returns its value', async () => {
235
+ const mockList = [
236
+ { id: 'bulk-1', nodeId: 'hub', startedAtMs: 1000, total: 1, completed: 0, failed: 0, current: 'pkg-a', phase: 'regular' as const, cancelled: false, items: [] },
237
+ ]
238
+ stubs.list.mockReturnValue(mockList)
239
+
240
+ const result = await env.provider.listActiveBulkUpdates({ nodeId: 'hub' })
241
+
242
+ expect(stubs.list).toHaveBeenCalledOnce()
243
+ expect(stubs.list).toHaveBeenCalledWith('hub')
244
+ expect(result).toEqual(mockList)
245
+ })
246
+
247
+ it('listActiveBulkUpdates passes undefined when nodeId is omitted', async () => {
248
+ stubs.list.mockReturnValue([])
249
+
250
+ await env.provider.listActiveBulkUpdates({})
251
+
252
+ expect(stubs.list).toHaveBeenCalledWith(undefined)
253
+ })
254
+ })
255
+
256
+ describe('buildAddonsProvider — listUpdates isSystem field', () => {
257
+ let env: ProviderEnv
258
+
259
+ beforeEach(() => {
260
+ vi.clearAllMocks()
261
+ env = createProviderEnv()
262
+ })
263
+
264
+ it('adds isSystem: true for packages in FRAMEWORK_PACKAGE_ALLOWLIST', async () => {
265
+ const systemPkg = FRAMEWORK_PACKAGE_ALLOWLIST[0]!
266
+ env.psMock['checkUpdates']!.mockResolvedValue([
267
+ {
268
+ name: systemPkg,
269
+ currentVersion: '0.1.38',
270
+ latestVersion: '0.1.40',
271
+ category: 'core',
272
+ requiresRestart: true,
273
+ },
274
+ ])
275
+
276
+ const result = await env.provider.listUpdates({ nodeId: 'hub' })
277
+
278
+ expect(result).toHaveLength(1)
279
+ expect(result[0]!.name).toBe(systemPkg)
280
+ expect(result[0]!.isSystem).toBe(true)
281
+ })
282
+
283
+ it('adds isSystem: false for non-framework packages', async () => {
284
+ env.psMock['checkUpdates']!.mockResolvedValue([
285
+ {
286
+ name: '@camstack/addon-stream-broker',
287
+ currentVersion: '1.0.0',
288
+ latestVersion: '1.0.1',
289
+ category: 'addon',
290
+ requiresRestart: false,
291
+ },
292
+ ])
293
+
294
+ const result = await env.provider.listUpdates({ nodeId: 'hub' })
295
+
296
+ expect(result).toHaveLength(1)
297
+ expect(result[0]!.isSystem).toBe(false)
298
+ })
299
+
300
+ it('handles mixed system and non-system packages correctly', async () => {
301
+ env.psMock['checkUpdates']!.mockResolvedValue([
302
+ {
303
+ name: '@camstack/types',
304
+ currentVersion: '0.1.38',
305
+ latestVersion: '0.1.40',
306
+ category: 'core',
307
+ requiresRestart: true,
308
+ },
309
+ {
310
+ name: '@camstack/addon-foo',
311
+ currentVersion: '1.0.0',
312
+ latestVersion: '1.0.1',
313
+ category: 'addon',
314
+ requiresRestart: false,
315
+ },
316
+ ])
317
+
318
+ const result = await env.provider.listUpdates({ nodeId: 'hub' })
319
+
320
+ expect(result).toHaveLength(2)
321
+ const typesRow = result.find(r => r.name === '@camstack/types')
322
+ const fooRow = result.find(r => r.name === '@camstack/addon-foo')
323
+ expect(typesRow?.isSystem).toBe(true)
324
+ expect(fooRow?.isSystem).toBe(false)
325
+ })
326
+
327
+ it('all FRAMEWORK_PACKAGE_ALLOWLIST members get isSystem: true', async () => {
328
+ env.psMock['checkUpdates']!.mockResolvedValue(
329
+ FRAMEWORK_PACKAGE_ALLOWLIST.map(name => ({
330
+ name,
331
+ currentVersion: '0.1.0',
332
+ latestVersion: '0.1.1',
333
+ category: 'core',
334
+ requiresRestart: true,
335
+ })),
336
+ )
337
+
338
+ const result = await env.provider.listUpdates({ nodeId: 'hub' })
339
+
340
+ expect(result).toHaveLength(FRAMEWORK_PACKAGE_ALLOWLIST.length)
341
+ for (const row of result) {
342
+ expect(row.isSystem).toBe(true)
343
+ }
344
+ })
345
+ })
346
+
347
+ describe('buildAddonsProvider — updateFrameworkPackage deferRestart propagation', () => {
348
+ let env: ProviderEnv
349
+
350
+ beforeEach(() => {
351
+ vi.clearAllMocks()
352
+ env = createProviderEnv()
353
+ })
354
+
355
+ it('forwards deferRestart: true to the service', async () => {
356
+ await env.provider.updateFrameworkPackage({
357
+ packageName: '@camstack/types',
358
+ version: '0.1.40',
359
+ deferRestart: true,
360
+ })
361
+
362
+ expect(env.psMock['updateFrameworkPackage']).toHaveBeenCalledOnce()
363
+ const callArg = env.psMock['updateFrameworkPackage']!.mock.calls[0]![0] as Record<string, unknown>
364
+ expect(callArg['deferRestart']).toBe(true)
365
+ })
366
+
367
+ it('forwards deferRestart: false to the service', async () => {
368
+ await env.provider.updateFrameworkPackage({
369
+ packageName: '@camstack/types',
370
+ version: '0.1.40',
371
+ deferRestart: false,
372
+ })
373
+
374
+ const callArg = env.psMock['updateFrameworkPackage']!.mock.calls[0]![0] as Record<string, unknown>
375
+ expect(callArg['deferRestart']).toBe(false)
376
+ })
377
+
378
+ it('does not include deferRestart when it is omitted', async () => {
379
+ await env.provider.updateFrameworkPackage({
380
+ packageName: '@camstack/types',
381
+ version: '0.1.40',
382
+ })
383
+
384
+ const callArg = env.psMock['updateFrameworkPackage']!.mock.calls[0]![0] as Record<string, unknown>
385
+ // Either undefined or not present — both are acceptable
386
+ expect(callArg['deferRestart']).toBeUndefined()
387
+ })
388
+ })
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Unit tests for the two adapter factories and the resolver wired with them.
3
+ *
4
+ * Exercises:
5
+ * - createNodeCapAuthority: delegates to HubNodeRegistry correctly
6
+ * - createInProcessProviderLookup: invokes provider methods cast-free
7
+ * - Resolver wired with both adapters:
8
+ * hub-local UDS child cap resolves via callCapOnChild
9
+ * hub-resident singleton resolves in-process (invoke called)
10
+ * absent cap throws CapRouteError (NOT the old opaque string)
11
+ *
12
+ * We test the adapter factories directly + a resolver wired with them —
13
+ * standing up a full MoleculerService is too heavy for a unit test, and
14
+ * testing the adapters + resolver in isolation exercises the meaningful unit.
15
+ */
16
+
17
+ import { describe, it, expect, vi } from 'vitest'
18
+ import { CapRouteError, CapRouteResolver } from '@camstack/kernel'
19
+ import type {
20
+ NodeCapAuthority,
21
+ InProcessProviderLookup,
22
+ HubLocalChildDispatcher,
23
+ CapRouteResolverDeps,
24
+ } from '@camstack/kernel'
25
+ import type { InProcessProviderRef } from '@camstack/kernel'
26
+ import {
27
+ createNodeCapAuthority,
28
+ createInProcessProviderLookup,
29
+ } from '../core/moleculer/cap-route-authority.js'
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Helpers for stub HubNodeRegistry
33
+ // ---------------------------------------------------------------------------
34
+
35
+ interface StubNodeEntry {
36
+ readonly addonId: string
37
+ readonly capabilities: readonly string[]
38
+ }
39
+
40
+ function makeNodeRegistry(nodes: ReadonlyMap<string, readonly StubNodeEntry[]>) {
41
+ return {
42
+ getNodeManifest(nodeId: string) {
43
+ return nodes.get(nodeId)
44
+ },
45
+ listNodeIds(): readonly string[] {
46
+ return [...nodes.keys()]
47
+ },
48
+ }
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Helpers for stub CapabilityService
53
+ // ---------------------------------------------------------------------------
54
+
55
+ function makeCapabilityService(
56
+ providers: ReadonlyMap<string, Record<string, unknown>>,
57
+ ) {
58
+ return {
59
+ getSingleton<T>(capability: string): T | null {
60
+ return (providers.get(capability) as T) ?? null
61
+ },
62
+ }
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // createNodeCapAuthority
67
+ // ---------------------------------------------------------------------------
68
+
69
+ describe('createNodeCapAuthority', () => {
70
+ const nodes = new Map([
71
+ ['hub/stream-broker', [{ addonId: 'addon-stream-broker', capabilities: ['stream-broker', 'stream-params'] }]],
72
+ ['dev-agent-0', [{ addonId: 'addon-detection-pipeline', capabilities: ['pipeline-executor'] }]],
73
+ ])
74
+ const registry = makeNodeRegistry(nodes)
75
+ const authority = createNodeCapAuthority(registry)
76
+
77
+ it('nodeKnowsCap returns true when the node manifest includes the cap', () => {
78
+ expect(authority.nodeKnowsCap('hub/stream-broker', 'stream-broker')).toBe(true)
79
+ expect(authority.nodeKnowsCap('hub/stream-broker', 'stream-params')).toBe(true)
80
+ expect(authority.nodeKnowsCap('hub/stream-broker', 'ghost-cap')).toBe(false)
81
+ })
82
+
83
+ it('nodeKnowsCap returns false for unknown nodes', () => {
84
+ expect(authority.nodeKnowsCap('not-a-node', 'stream-broker')).toBe(false)
85
+ })
86
+
87
+ it('getAddonId returns the addonId for a known cap', () => {
88
+ expect(authority.getAddonId('hub/stream-broker', 'stream-broker')).toBe('addon-stream-broker')
89
+ expect(authority.getAddonId('dev-agent-0', 'pipeline-executor')).toBe('addon-detection-pipeline')
90
+ })
91
+
92
+ it('getAddonId returns null for missing nodes or caps', () => {
93
+ expect(authority.getAddonId('not-a-node', 'stream-broker')).toBeNull()
94
+ expect(authority.getAddonId('hub/stream-broker', 'ghost-cap')).toBeNull()
95
+ })
96
+
97
+ it('nodeIsAgent: hub and hub-children are NOT agents; bare-id non-hub nodes are agents', () => {
98
+ expect(authority.nodeIsAgent('hub')).toBe(false)
99
+ expect(authority.nodeIsAgent('hub/stream-broker')).toBe(false)
100
+ expect(authority.nodeIsAgent('dev-agent-0')).toBe(true)
101
+ expect(authority.nodeIsAgent('some-remote-agent')).toBe(true)
102
+ })
103
+
104
+ it('nodeOnline: nodes in the registry are online; absent ones are not', () => {
105
+ expect(authority.nodeOnline('hub/stream-broker')).toBe(true)
106
+ expect(authority.nodeOnline('dev-agent-0')).toBe(true)
107
+ expect(authority.nodeOnline('not-registered')).toBe(false)
108
+ })
109
+
110
+ it('listNodeIds returns all registered node ids', () => {
111
+ const ids = authority.listNodeIds()
112
+ expect(ids).toContain('hub/stream-broker')
113
+ expect(ids).toContain('dev-agent-0')
114
+ expect(ids).toHaveLength(2)
115
+ })
116
+
117
+ it('getAgentChildId always returns null (hub cannot resolve; Task 6 handles it)', () => {
118
+ expect(authority.getAgentChildId('dev-agent-0', 'pipeline-executor')).toBeNull()
119
+ })
120
+ })
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // createNodeCapAuthority — per-node singleton override
124
+ // ---------------------------------------------------------------------------
125
+
126
+ describe('createNodeCapAuthority — per-node singleton override', () => {
127
+ it('getAddonId honors the per-node singleton override when available', () => {
128
+ const nodeRegistry = {
129
+ getNodeManifest: (id: string) => id === 'dev-agent-0'
130
+ ? [{ addonId: 'webrtc-native', capabilities: ['webrtc-session'] },
131
+ { addonId: 'stream-broker', capabilities: ['webrtc-session'] }]
132
+ : undefined,
133
+ listNodeIds: () => ['hub', 'dev-agent-0'],
134
+ }
135
+ const authority = createNodeCapAuthority(nodeRegistry, {
136
+ resolveSingleton: (cap, nodeId) =>
137
+ cap === 'webrtc-session' && nodeId === 'dev-agent-0' ? 'stream-broker' : null,
138
+ })
139
+ expect(authority.getAddonId('dev-agent-0', 'webrtc-session')).toBe('stream-broker')
140
+ })
141
+
142
+ it('getAddonId falls back to first manifest match without an override', () => {
143
+ const nodeRegistry = {
144
+ getNodeManifest: (id: string) => id === 'dev-agent-0'
145
+ ? [{ addonId: 'webrtc-native', capabilities: ['webrtc-session'] }]
146
+ : undefined,
147
+ listNodeIds: () => ['hub', 'dev-agent-0'],
148
+ }
149
+ const authority = createNodeCapAuthority(nodeRegistry, { resolveSingleton: () => null })
150
+ expect(authority.getAddonId('dev-agent-0', 'webrtc-session')).toBe('webrtc-native')
151
+ })
152
+ })
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // createInProcessProviderLookup
156
+ // ---------------------------------------------------------------------------
157
+
158
+ describe('createInProcessProviderLookup', () => {
159
+ it('returns null for a cap not hosted in-process', () => {
160
+ const lookup = createInProcessProviderLookup(makeCapabilityService(new Map()))
161
+ expect(lookup('device-manager')).toBeNull()
162
+ })
163
+
164
+ it('returns an InProcessProviderRef whose invoke delegates to the provider method', async () => {
165
+ const getStatusImpl = vi.fn().mockReturnValue({ ok: true })
166
+ const providers = new Map<string, Record<string, unknown>>([
167
+ ['device-manager', { getStatus: getStatusImpl }],
168
+ ])
169
+ const lookup = createInProcessProviderLookup(makeCapabilityService(providers))
170
+
171
+ const ref = lookup('device-manager')
172
+ expect(ref).not.toBeNull()
173
+
174
+ const result = await ref!.invoke('getStatus', { deviceId: 1 })
175
+ expect(result).toEqual({ ok: true })
176
+ expect(getStatusImpl).toHaveBeenCalledOnce()
177
+ expect(getStatusImpl).toHaveBeenCalledWith({ deviceId: 1 })
178
+ })
179
+
180
+ it('invoke throws a typed Error when the method is not a function (never casts)', async () => {
181
+ const providers = new Map<string, Record<string, unknown>>([
182
+ ['device-manager', { notAFn: 'some-string' }],
183
+ ])
184
+ const lookup = createInProcessProviderLookup(makeCapabilityService(providers))
185
+ const ref = lookup('device-manager')
186
+ expect(ref).not.toBeNull()
187
+
188
+ await expect(ref!.invoke('notAFn', {})).rejects.toThrow(/method "notAFn" not found/)
189
+ await expect(ref!.invoke('missingMethod', {})).rejects.toThrow(/method "missingMethod" not found/)
190
+ })
191
+ })
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // Resolver wired with both adapters
195
+ // ---------------------------------------------------------------------------
196
+
197
+ describe('Resolver + adapters — end-to-end dispatch', () => {
198
+ const HUB_NODE_ID = 'hub'
199
+
200
+ function makeCallCapOnChildSpy() {
201
+ return vi.fn(async (_childId: string, _input: unknown) => ({ ok: true, from: 'uds' }))
202
+ }
203
+
204
+ function makeHubLocalRegistry(caps: ReadonlyMap<string, string>): HubLocalChildDispatcher & { callSpy: ReturnType<typeof vi.fn> } {
205
+ const callSpy = makeCallCapOnChildSpy()
206
+ return {
207
+ resolveChildId: (capName: string) => caps.get(capName) ?? null,
208
+ callCapOnChild: callSpy,
209
+ callSpy,
210
+ }
211
+ }
212
+
213
+ it('hub-local UDS child cap resolves via callCapOnChild', async () => {
214
+ // Registry: stream-broker is served by a hub-local child.
215
+ const nodes = new Map([
216
+ ['hub/stream-broker', [{ addonId: 'addon-stream-broker', capabilities: ['stream-broker'] }]],
217
+ ])
218
+ const nodeRegistry = makeNodeRegistry(nodes)
219
+ const authority = createNodeCapAuthority(nodeRegistry)
220
+
221
+ const hubLocalCaps = new Map([['stream-broker', 'addon-stream-broker']])
222
+ const hubLocalRegistry = makeHubLocalRegistry(hubLocalCaps)
223
+
224
+ const lookup = createInProcessProviderLookup(makeCapabilityService(new Map()))
225
+
226
+ const deps: CapRouteResolverDeps = {
227
+ hubNodeId: HUB_NODE_ID,
228
+ broker: { call: vi.fn(), waitForServices: vi.fn() },
229
+ hubLocalRegistry,
230
+ nodeAuthority: authority,
231
+ inProcessProviders: lookup,
232
+ }
233
+
234
+ const resolver = new CapRouteResolver(deps)
235
+ const route = resolver.resolveCapRoute('stream-broker', { nodeId: HUB_NODE_ID })
236
+ expect(route.kind).toBe('hub-local-uds')
237
+
238
+ const result = await resolver.dispatch(route, 'attachCamera', { deviceId: 5 })
239
+ expect(result).toEqual({ ok: true, from: 'uds' })
240
+ expect(hubLocalRegistry.callSpy).toHaveBeenCalledOnce()
241
+ })
242
+
243
+ it('hub-resident singleton resolves in-process via invoke', async () => {
244
+ const invokeSpy = vi.fn().mockResolvedValue({ devices: [] })
245
+ const providers = new Map<string, Record<string, unknown>>([
246
+ ['device-manager', { listAll: invokeSpy }],
247
+ ])
248
+ const lookup = createInProcessProviderLookup(makeCapabilityService(providers))
249
+
250
+ const deps: CapRouteResolverDeps = {
251
+ hubNodeId: HUB_NODE_ID,
252
+ broker: { call: vi.fn(), waitForServices: vi.fn() },
253
+ hubLocalRegistry: null,
254
+ nodeAuthority: createNodeCapAuthority(makeNodeRegistry(new Map())),
255
+ inProcessProviders: lookup,
256
+ }
257
+
258
+ const resolver = new CapRouteResolver(deps)
259
+ const route = resolver.resolveCapRoute('device-manager', { nodeId: HUB_NODE_ID })
260
+ expect(route.kind).toBe('hub-in-process')
261
+
262
+ const result = await resolver.dispatch(route, 'listAll', {})
263
+ expect(result).toEqual({ devices: [] })
264
+ expect(invokeSpy).toHaveBeenCalledOnce()
265
+ })
266
+
267
+ it('genuinely-absent cap throws CapRouteError, NOT the old opaque string', () => {
268
+ const deps: CapRouteResolverDeps = {
269
+ hubNodeId: HUB_NODE_ID,
270
+ broker: { call: vi.fn(), waitForServices: vi.fn() },
271
+ hubLocalRegistry: null,
272
+ nodeAuthority: createNodeCapAuthority(makeNodeRegistry(new Map())),
273
+ inProcessProviders: createInProcessProviderLookup(makeCapabilityService(new Map())),
274
+ }
275
+ const resolver = new CapRouteResolver(deps)
276
+
277
+ let thrown: unknown
278
+ try {
279
+ resolver.resolveCapRoute('ghost-cap', {})
280
+ } catch (e) {
281
+ thrown = e
282
+ }
283
+
284
+ expect(thrown).toBeInstanceOf(CapRouteError)
285
+ expect((thrown as CapRouteError).reason).toBe('no-provider')
286
+ // Must NOT be the old opaque string
287
+ expect((thrown as CapRouteError).message).not.toContain('Capability "ghost-cap" not available on node')
288
+ })
289
+ })