@camstack/server 0.1.5 → 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.
- package/package.json +1 -1
- package/src/__tests__/addon-upload.spec.ts +58 -0
- package/src/__tests__/bulk-update-coordinator.spec.ts +286 -0
- package/src/__tests__/cap-ownership-authority.spec.ts +400 -0
- package/src/__tests__/cap-providers-bulk-update.spec.ts +388 -0
- package/src/__tests__/cap-route-adapter.spec.ts +289 -0
- package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +123 -0
- package/src/__tests__/cap-routers/capabilities-node.spec.ts +55 -0
- package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +30 -0
- package/src/__tests__/device-settings-contribution-dispatch.spec.ts +249 -0
- package/src/__tests__/framework-installer-defer-restart.spec.ts +165 -0
- package/src/__tests__/moleculer/uds-readiness.spec.ts +143 -0
- package/src/__tests__/moleculer/uds-topology.spec.ts +390 -0
- package/src/__tests__/moleculer/uds-unowned-call.spec.ts +123 -0
- package/src/__tests__/moleculer-register-node-idempotency.spec.ts +39 -4
- package/src/__tests__/native-cap-route.spec.ts +404 -0
- package/src/__tests__/oauth2-account-linking.spec.ts +85 -0
- package/src/__tests__/uds-addon-call-wiring.spec.ts +237 -0
- package/src/__tests__/uds-log-ingest.spec.ts +183 -0
- package/src/api/addon-upload.ts +27 -1
- package/src/api/capabilities.router.ts +1 -1
- package/src/api/core/bulk-update-coordinator.ts +302 -0
- package/src/api/core/cap-providers.ts +59 -6
- package/src/api/core/capabilities.router.ts +26 -3
- package/src/api/oauth2/oauth2-routes.ts +5 -1
- package/src/api/trpc/__tests__/client-ip.spec.ts +120 -0
- package/src/api/trpc/cap-route-error-formatter.ts +163 -0
- package/src/api/trpc/client-ip.ts +130 -0
- package/src/api/trpc/generated-cap-mounts.ts +19 -1
- package/src/api/trpc/generated-cap-routers.ts +180 -1
- package/src/api/trpc/trpc.middleware.ts +5 -1
- package/src/api/trpc/trpc.router.ts +45 -0
- package/src/core/addon/addon-call-gateway.ts +157 -0
- package/src/core/addon/addon-package.service.ts +9 -0
- package/src/core/addon/addon-registry.service.ts +364 -105
- package/src/core/addon/addon-settings-provider.ts +40 -116
- package/src/core/capability/capability.service.ts +9 -0
- package/src/core/moleculer/cap-call-fn.spec.ts +166 -0
- package/src/core/moleculer/cap-call-fn.ts +103 -0
- package/src/core/moleculer/cap-route-authority.ts +182 -0
- package/src/core/moleculer/moleculer.service.ts +380 -36
- 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
|
+
})
|