@camstack/server 0.1.7 → 0.2.0

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 (135) hide show
  1. package/package.json +11 -9
  2. package/src/__tests__/addon-install-e2e.test.ts +0 -1
  3. package/src/__tests__/addon-pages-e2e.test.ts +40 -18
  4. package/src/__tests__/addon-settings-router.spec.ts +6 -1
  5. package/src/__tests__/addon-upload.spec.ts +91 -29
  6. package/src/__tests__/agent-registry.spec.ts +26 -9
  7. package/src/__tests__/agent-status-page.spec.ts +1 -3
  8. package/src/__tests__/auth-session-cookie.test.ts +28 -1
  9. package/src/__tests__/bulk-update-coordinator.spec.ts +48 -31
  10. package/src/__tests__/cap-ownership-authority.spec.ts +39 -8
  11. package/src/__tests__/cap-providers/cap-providers-location-import.spec.ts +206 -0
  12. package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +17 -3
  13. package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +57 -11
  14. package/src/__tests__/cap-providers/integrations-delete-cascade.spec.ts +292 -0
  15. package/src/__tests__/cap-providers-bulk-update.spec.ts +27 -7
  16. package/src/__tests__/cap-route-adapter.spec.ts +28 -15
  17. package/src/__tests__/cap-routers/_meta.spec.ts +6 -7
  18. package/src/__tests__/cap-routers/addon-settings.router.spec.ts +19 -10
  19. package/src/__tests__/cap-routers/broker-routing.router.spec.ts +177 -0
  20. package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +3 -1
  21. package/src/__tests__/cap-routers/capabilities-node.spec.ts +18 -5
  22. package/src/__tests__/cap-routers/device-link-overlay.spec.ts +137 -0
  23. package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +72 -20
  24. package/src/__tests__/cap-routers/harness.ts +11 -7
  25. package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +17 -3
  26. package/src/__tests__/cap-routers/null-provider-guard.spec.ts +5 -7
  27. package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +35 -11
  28. package/src/__tests__/cap-routers/settings-store.router.spec.ts +59 -15
  29. package/src/__tests__/capability-e2e.test.ts +9 -11
  30. package/src/__tests__/cli-e2e.test.ts +80 -59
  31. package/src/__tests__/core-cap-bridge.spec.ts +3 -1
  32. package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +12 -2
  33. package/src/__tests__/device-settings-contribution-dispatch.spec.ts +61 -30
  34. package/src/__tests__/embedded-deps-e2e.test.ts +35 -19
  35. package/src/__tests__/event-bus-proxy-router.spec.ts +3 -0
  36. package/src/__tests__/framework-allowlist.spec.ts +5 -4
  37. package/src/__tests__/https-e2e.test.ts +12 -6
  38. package/src/__tests__/lifecycle-e2e.test.ts +60 -11
  39. package/src/__tests__/live-events-subscription.spec.ts +17 -18
  40. package/src/__tests__/moleculer/uds-readiness.spec.ts +11 -4
  41. package/src/__tests__/moleculer/uds-topology.spec.ts +39 -11
  42. package/src/__tests__/moleculer/uds-unowned-call.spec.ts +265 -5
  43. package/src/__tests__/moleculer-register-node-idempotency.spec.ts +16 -7
  44. package/src/__tests__/native-cap-route.spec.ts +42 -19
  45. package/src/__tests__/oauth2-account-linking.spec.ts +63 -17
  46. package/src/__tests__/singleton-contention.test.ts +23 -11
  47. package/src/__tests__/streaming-diagnostic.test.ts +156 -53
  48. package/src/__tests__/streaming-scale.test.ts +69 -35
  49. package/src/__tests__/uds-addon-call-wiring.spec.ts +6 -1
  50. package/src/agent-status-page.ts +4 -3
  51. package/src/api/__tests__/addons-custom.spec.ts +22 -8
  52. package/src/api/__tests__/capabilities.router.test.ts +18 -9
  53. package/src/api/addon-upload.ts +46 -15
  54. package/src/api/addons-custom.router.ts +7 -6
  55. package/src/api/auth-whoami.ts +3 -1
  56. package/src/api/bridge-addons.router.ts +3 -1
  57. package/src/api/capabilities.router.ts +117 -78
  58. package/src/api/core/__tests__/auth-router-totp.spec.ts +57 -16
  59. package/src/api/core/__tests__/integration-markers.spec.ts +10 -0
  60. package/src/api/core/addon-settings.router.ts +4 -1
  61. package/src/api/core/agents.router.ts +52 -53
  62. package/src/api/core/auth.router.ts +55 -36
  63. package/src/api/core/bulk-update-coordinator.ts +25 -22
  64. package/src/api/core/cap-providers.ts +459 -166
  65. package/src/api/core/capabilities.router.ts +30 -23
  66. package/src/api/core/hwaccel.router.ts +37 -10
  67. package/src/api/core/live-events.router.ts +16 -9
  68. package/src/api/core/logs.router.ts +58 -25
  69. package/src/api/core/notifications.router.ts +2 -1
  70. package/src/api/core/repl.router.ts +1 -3
  71. package/src/api/core/settings-backend.router.ts +68 -70
  72. package/src/api/core/system-events.router.ts +41 -32
  73. package/src/api/health/health.routes.ts +7 -13
  74. package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +12 -2
  75. package/src/api/oauth2/consent-page.ts +4 -3
  76. package/src/api/oauth2/oauth2-routes.ts +41 -12
  77. package/src/api/trpc/__tests__/client-ip.spec.ts +27 -1
  78. package/src/api/trpc/__tests__/scope-access-device.spec.ts +68 -23
  79. package/src/api/trpc/__tests__/scope-access.spec.ts +8 -13
  80. package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +136 -0
  81. package/src/api/trpc/cap-mount-helpers.ts +64 -44
  82. package/src/api/trpc/cap-route-error-formatter.ts +17 -9
  83. package/src/api/trpc/client-ip.ts +17 -0
  84. package/src/api/trpc/core-cap-bridge.ts +3 -1
  85. package/src/api/trpc/generated-cap-mounts.ts +801 -286
  86. package/src/api/trpc/generated-cap-routers.ts +5723 -719
  87. package/src/api/trpc/scope-access.ts +7 -7
  88. package/src/api/trpc/trpc.context.ts +7 -4
  89. package/src/api/trpc/trpc.middleware.ts +4 -2
  90. package/src/api/trpc/trpc.router.ts +117 -48
  91. package/src/auth/session-cookie.ts +10 -0
  92. package/src/boot/__tests__/integration-id-backfill.spec.ts +131 -0
  93. package/src/boot/boot-config.ts +103 -122
  94. package/src/boot/integration-id-backfill.ts +109 -0
  95. package/src/boot/post-boot.service.ts +5 -3
  96. package/src/core/addon/__tests__/addon-registry-capability.test.ts +12 -3
  97. package/src/core/addon/__tests__/addon-row-manifest.spec.ts +62 -0
  98. package/src/core/addon/addon-call-gateway.ts +20 -6
  99. package/src/core/addon/addon-package.service.ts +183 -89
  100. package/src/core/addon/addon-registry.service.ts +1212 -1267
  101. package/src/core/addon/addon-row-manifest.ts +29 -0
  102. package/src/core/addon/addon-search.service.ts +2 -1
  103. package/src/core/addon/addon-settings-provider.ts +27 -7
  104. package/src/core/addon-bridge/addon-bridge.service.ts +11 -6
  105. package/src/core/addon-pages/addon-pages.service.ts +3 -1
  106. package/src/core/addon-widgets/addon-widgets.service.ts +5 -2
  107. package/src/core/agent/agent-registry.service.ts +60 -38
  108. package/src/core/auth/auth.service.spec.ts +6 -8
  109. package/src/core/config/config.service.spec.ts +1 -1
  110. package/src/core/events/event-bus.service.spec.ts +44 -21
  111. package/src/core/events/event-bus.service.ts +5 -1
  112. package/src/core/feature/feature.service.spec.ts +4 -1
  113. package/src/core/lifecycle/lifecycle-state-machine.spec.ts +8 -10
  114. package/src/core/logging/logging.service.spec.ts +61 -21
  115. package/src/core/logging/logging.service.ts +19 -5
  116. package/src/core/moleculer/cap-call-fn.spec.ts +17 -10
  117. package/src/core/moleculer/cap-call-fn.ts +5 -1
  118. package/src/core/moleculer/cap-route-authority.ts +18 -6
  119. package/src/core/moleculer/moleculer.service.ts +145 -29
  120. package/src/core/network/network-quality.service.spec.ts +7 -1
  121. package/src/core/notification/notification-wrapper.service.ts +1 -3
  122. package/src/core/notification/toast-wrapper.service.ts +1 -5
  123. package/src/core/repl/repl-engine.service.spec.ts +66 -39
  124. package/src/core/repl/repl-engine.service.ts +11 -12
  125. package/src/core/storage/storage-location-manager.spec.ts +12 -3
  126. package/src/core/streaming/stream-probe.service.ts +22 -13
  127. package/src/core/topology/topology-emitter.service.ts +5 -1
  128. package/src/launcher.ts +14 -9
  129. package/src/main.ts +658 -495
  130. package/src/manual-boot.ts +133 -154
  131. package/tsconfig.json +20 -8
  132. package/src/core/storage/settings-store.spec.ts +0 -213
  133. package/src/core/storage/settings-store.ts +0 -2
  134. package/src/core/storage/sql-schema.spec.ts +0 -140
  135. package/src/core/storage/sql-schema.ts +0 -3
@@ -22,9 +22,11 @@ function makeRig(opts?: { failItems?: ReadonlySet<string> }): TestRig {
22
22
  const updateAddon = vi.fn(async (input: { name: string; version: string }) => {
23
23
  if (fail.has(input.name)) throw new Error(`mock fail ${input.name}`)
24
24
  })
25
- const updateFrameworkPackage = vi.fn(async (input: { packageName: string; version: string; deferRestart: boolean }) => {
26
- if (fail.has(input.packageName)) throw new Error(`mock fail fw ${input.packageName}`)
27
- })
25
+ const updateFrameworkPackage = vi.fn(
26
+ async (input: { packageName: string; version: string; deferRestart: boolean }) => {
27
+ if (fail.has(input.packageName)) throw new Error(`mock fail fw ${input.packageName}`)
28
+ },
29
+ )
28
30
  const restartServer = vi.fn(async () => {
29
31
  // simulates the real restartServer: in a real run the process dies
30
32
  })
@@ -40,7 +42,12 @@ function makeRig(opts?: { failItems?: ReadonlySet<string> }): TestRig {
40
42
  updateAddon,
41
43
  updateFrameworkPackage,
42
44
  restartServer,
43
- logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as unknown as BulkUpdateCoordinatorDeps['logger'],
45
+ logger: {
46
+ info: vi.fn(),
47
+ warn: vi.fn(),
48
+ error: vi.fn(),
49
+ debug: vi.fn(),
50
+ } as unknown as BulkUpdateCoordinatorDeps['logger'],
44
51
  clock: () => clock++,
45
52
  }
46
53
 
@@ -55,8 +62,12 @@ function makeRig(opts?: { failItems?: ReadonlySet<string> }): TestRig {
55
62
  }
56
63
 
57
64
  describe('BulkUpdateCoordinator', () => {
58
- beforeEach(() => { vi.useFakeTimers() })
59
- afterEach(() => { vi.useRealTimers() })
65
+ beforeEach(() => {
66
+ vi.useFakeTimers()
67
+ })
68
+ afterEach(() => {
69
+ vi.useRealTimers()
70
+ })
60
71
 
61
72
  it('regular-only happy path: processes 3 addons in order, no restart', async () => {
62
73
  const rig = makeRig()
@@ -75,7 +86,7 @@ describe('BulkUpdateCoordinator', () => {
75
86
  expect(final.completed).toBe(3)
76
87
  expect(final.failed).toBe(0)
77
88
  expect(final.phase).toBe('finalizing')
78
- expect(final.items.every(i => i.status === 'done')).toBe(true)
89
+ expect(final.items.every((i) => i.status === 'done')).toBe(true)
79
90
  expect(rig.restartServer).not.toHaveBeenCalled()
80
91
  expect(rig.updateAddon).toHaveBeenCalledTimes(3)
81
92
  })
@@ -98,15 +109,19 @@ describe('BulkUpdateCoordinator', () => {
98
109
  expect(final.completed).toBe(4)
99
110
  expect(rig.updateAddon).toHaveBeenCalledTimes(2)
100
111
  expect(rig.updateFrameworkPackage).toHaveBeenCalledTimes(2)
101
- expect(rig.updateFrameworkPackage).toHaveBeenCalledWith(expect.objectContaining({ deferRestart: true }))
112
+ expect(rig.updateFrameworkPackage).toHaveBeenCalledWith(
113
+ expect.objectContaining({ deferRestart: true }),
114
+ )
102
115
  expect(rig.restartServer).toHaveBeenCalledOnce()
103
116
 
104
117
  // Verify ordering via event sequence: regular items reach 'updating' before any system item
105
- const updatingNames = rig.events
106
- .map(s => s.current)
107
- .filter((n): n is string => n !== null)
108
- const firstSystemIdx = updatingNames.findIndex(n => n === '@camstack/types' || n === '@camstack/kernel')
109
- const lastRegularIdx = updatingNames.findLastIndex(n => n === '@camstack/addon-a' || n === '@camstack/addon-b')
118
+ const updatingNames = rig.events.map((s) => s.current).filter((n): n is string => n !== null)
119
+ const firstSystemIdx = updatingNames.findIndex(
120
+ (n) => n === '@camstack/types' || n === '@camstack/kernel',
121
+ )
122
+ const lastRegularIdx = updatingNames.findLastIndex(
123
+ (n) => n === '@camstack/addon-a' || n === '@camstack/addon-b',
124
+ )
110
125
  expect(firstSystemIdx).toBeGreaterThan(lastRegularIdx)
111
126
  })
112
127
 
@@ -125,17 +140,19 @@ describe('BulkUpdateCoordinator', () => {
125
140
 
126
141
  const final = rig.coordinator.get(id)!
127
142
  expect(final.failed).toBe(1)
128
- expect(final.items.find(i => i.name === '@camstack/addon-b')?.status).toBe('failed')
129
- expect(final.items.find(i => i.name === '@camstack/addon-b')?.error).toContain('mock fail')
130
- expect(final.items.find(i => i.name === '@camstack/addon-a')?.status).toBe('done')
131
- expect(final.items.find(i => i.name === '@camstack/addon-c')?.status).toBe('done')
143
+ expect(final.items.find((i) => i.name === '@camstack/addon-b')?.status).toBe('failed')
144
+ expect(final.items.find((i) => i.name === '@camstack/addon-b')?.error).toContain('mock fail')
145
+ expect(final.items.find((i) => i.name === '@camstack/addon-a')?.status).toBe('done')
146
+ expect(final.items.find((i) => i.name === '@camstack/addon-c')?.status).toBe('done')
132
147
  })
133
148
 
134
149
  it('cancel pre-restart: loop exits, no restart, queued items remain queued', async () => {
135
150
  const rig = makeRig()
136
151
  rig.updateAddon.mockImplementation(async () => {
137
152
  // Slow enough that cancel fires before item 2
138
- await new Promise<void>(resolve => { setTimeout(resolve, 50) })
153
+ await new Promise<void>((resolve) => {
154
+ setTimeout(resolve, 50)
155
+ })
139
156
  })
140
157
 
141
158
  const { id } = rig.coordinator.start({
@@ -157,21 +174,21 @@ describe('BulkUpdateCoordinator', () => {
157
174
  expect(final.cancelled).toBe(true)
158
175
  expect(rig.restartServer).not.toHaveBeenCalled()
159
176
  // At least one item should still be queued (we cancelled before item 2 ran)
160
- expect(final.items.filter(i => i.status === 'queued').length).toBeGreaterThanOrEqual(1)
177
+ expect(final.items.filter((i) => i.status === 'queued').length).toBeGreaterThanOrEqual(1)
161
178
  })
162
179
 
163
180
  it('cancel ignored during restarting phase', async () => {
164
181
  const rig = makeRig()
165
182
  rig.restartServer.mockImplementation(async () => {
166
183
  // Hang briefly so we can attempt cancel during restart
167
- await new Promise<void>(resolve => { setTimeout(resolve, 50) })
184
+ await new Promise<void>((resolve) => {
185
+ setTimeout(resolve, 50)
186
+ })
168
187
  })
169
188
 
170
189
  const { id } = rig.coordinator.start({
171
190
  nodeId: 'hub',
172
- items: [
173
- { name: '@camstack/types', version: '0.1.40', isSystem: true },
174
- ],
191
+ items: [{ name: '@camstack/types', version: '0.1.40', isSystem: true }],
175
192
  })
176
193
 
177
194
  // Let it reach restarting
@@ -191,15 +208,13 @@ describe('BulkUpdateCoordinator', () => {
191
208
 
192
209
  const { id } = rig.coordinator.start({
193
210
  nodeId: 'hub',
194
- items: [
195
- { name: '@camstack/types', version: '0.1.40', isSystem: true },
196
- ],
211
+ items: [{ name: '@camstack/types', version: '0.1.40', isSystem: true }],
197
212
  })
198
213
 
199
214
  await vi.runAllTimersAsync()
200
215
 
201
216
  const final = rig.coordinator.get(id)!
202
- const typesItem = final.items.find(i => i.name === '@camstack/types')!
217
+ const typesItem = final.items.find((i) => i.name === '@camstack/types')!
203
218
  expect(typesItem.status).toBe('done')
204
219
  expect(typesItem.error).toContain('Restart failed')
205
220
  })
@@ -211,10 +226,12 @@ describe('BulkUpdateCoordinator', () => {
211
226
  items: [{ name: '@camstack/addon-a', version: '1.0.1', isSystem: false }],
212
227
  })
213
228
 
214
- expect(() => rig.coordinator.start({
215
- nodeId: 'hub',
216
- items: [{ name: '@camstack/addon-b', version: '2.0.1', isSystem: false }],
217
- })).toThrow(/already in progress/i)
229
+ expect(() =>
230
+ rig.coordinator.start({
231
+ nodeId: 'hub',
232
+ items: [{ name: '@camstack/addon-b', version: '2.0.1', isSystem: false }],
233
+ }),
234
+ ).toThrow(/already in progress/i)
218
235
  })
219
236
 
220
237
  it('auto-cleanup: state purged 5 min after completedAt', async () => {
@@ -21,7 +21,11 @@
21
21
  */
22
22
 
23
23
  import { describe, it, expect, vi } from 'vitest'
24
- import type { NodeCapAuthority, HubLocalChildDispatcher, CapRouteResolverDeps } from '@camstack/kernel'
24
+ import type {
25
+ NodeCapAuthority,
26
+ HubLocalChildDispatcher,
27
+ CapRouteResolverDeps,
28
+ } from '@camstack/kernel'
25
29
  import { CapRouteResolver, CapRouteError } from '@camstack/kernel'
26
30
 
27
31
  // ---------------------------------------------------------------------------
@@ -40,8 +44,15 @@ import { CapRouteResolver, CapRouteError } from '@camstack/kernel'
40
44
  function buildConsolidatedNativeFallback(opts: {
41
45
  readonly resolver: CapRouteResolver | null
42
46
  readonly hubNodeId: string
43
- readonly resolveNativeCapOwnerSync: (capName: string, deviceId: number) => { addonId: string; nodeId: string } | null
44
- readonly buildNativeCapProxy: (addonId: string, capName: string, deviceId: number) => Record<string, unknown>
47
+ readonly resolveNativeCapOwnerSync: (
48
+ capName: string,
49
+ deviceId: number,
50
+ ) => { addonId: string; nodeId: string } | null
51
+ readonly buildNativeCapProxy: (
52
+ addonId: string,
53
+ capName: string,
54
+ deviceId: number,
55
+ ) => Record<string, unknown>
45
56
  }): (capName: string, deviceId: number) => unknown | null {
46
57
  const { resolver, hubNodeId, resolveNativeCapOwnerSync, buildNativeCapProxy } = opts
47
58
 
@@ -152,7 +163,9 @@ function makeNodeAuthority(
152
163
  getAgentChildId: (): string | null => null,
153
164
  isNativeCap: (nodeId: string, capName: string, deviceId?: number): boolean => {
154
165
  if (deviceId !== undefined) {
155
- return nativeCaps.some((n) => n.nodeId === nodeId && n.capName === capName && n.deviceId === deviceId)
166
+ return nativeCaps.some(
167
+ (n) => n.nodeId === nodeId && n.capName === capName && n.deviceId === deviceId,
168
+ )
156
169
  }
157
170
  return nativeCaps.some((n) => n.nodeId === nodeId && n.capName === capName)
158
171
  },
@@ -171,9 +184,18 @@ describe('G2 — (a) hub-local device-scoped native cap: resolver is consulted f
171
184
  new Map([['ptz', new Map([[7, 'provider-reolink']])]]),
172
185
  )
173
186
  const nativeCaps: NativeCapSpec[] = [
174
- { nodeId: 'hub/provider-reolink', addonId: 'addon-provider-reolink', capName: 'ptz', deviceId: 7 },
187
+ {
188
+ nodeId: 'hub/provider-reolink',
189
+ addonId: 'addon-provider-reolink',
190
+ capName: 'ptz',
191
+ deviceId: 7,
192
+ },
175
193
  ]
176
- const nodeAuthority = makeNodeAuthority(new Map(), new Set(['hub/provider-reolink']), nativeCaps)
194
+ const nodeAuthority = makeNodeAuthority(
195
+ new Map(),
196
+ new Set(['hub/provider-reolink']),
197
+ nativeCaps,
198
+ )
177
199
 
178
200
  const deps: CapRouteResolverDeps = {
179
201
  hubNodeId: HUB_NODE_ID,
@@ -211,9 +233,18 @@ describe('G2 — (a) hub-local device-scoped native cap: resolver is consulted f
211
233
  new Map([['ptz', new Map([[7, 'provider-reolink']])]]),
212
234
  )
213
235
  const nativeCaps: NativeCapSpec[] = [
214
- { nodeId: 'hub/provider-reolink', addonId: 'addon-provider-reolink', capName: 'ptz', deviceId: 7 },
236
+ {
237
+ nodeId: 'hub/provider-reolink',
238
+ addonId: 'addon-provider-reolink',
239
+ capName: 'ptz',
240
+ deviceId: 7,
241
+ },
215
242
  ]
216
- const nodeAuthority = makeNodeAuthority(new Map(), new Set(['hub/provider-reolink']), nativeCaps)
243
+ const nodeAuthority = makeNodeAuthority(
244
+ new Map(),
245
+ new Set(['hub/provider-reolink']),
246
+ nativeCaps,
247
+ )
217
248
 
218
249
  const deps: CapRouteResolverDeps = {
219
250
  hubNodeId: HUB_NODE_ID,
@@ -0,0 +1,206 @@
1
+ /**
2
+ * cap-providers-location-import.spec.ts
3
+ *
4
+ * Verifies that `buildIntegrationsProvider().getAvailableTypes()` correctly
5
+ * surfaces the `supportsLocationImport` flag:
6
+ * - A `device-adoption` addon whose manifest declares `supportsLocationImport: true`
7
+ * → returned entry has `supportsLocationImport === true`.
8
+ * - A `device-adoption` addon WITHOUT the flag
9
+ * → returned entry has `supportsLocationImport === false`.
10
+ * - A `device-provider` addon (non-adoption kind), regardless of the flag
11
+ * → returned entry has `supportsLocationImport === false`.
12
+ *
13
+ * Harness mirrors integrations-delete-cascade.spec.ts in the same directory.
14
+ */
15
+ import { describe, it, expect, vi } from 'vitest'
16
+ import { buildIntegrationsProvider } from '../../api/core/cap-providers.js'
17
+
18
+ // ── Minimal stubs ────────────────────────────────────────────────────────────
19
+
20
+ function makeIntegrationRegistry() {
21
+ return {
22
+ listIntegrations: vi.fn(async () => []),
23
+ getIntegration: vi.fn(async (_id: string) => null),
24
+ deleteIntegration: vi.fn(async (_id: string) => undefined),
25
+ createIntegration: vi.fn(),
26
+ updateIntegration: vi.fn(),
27
+ getIntegrationByAddonId: vi.fn(),
28
+ getIntegrationSettings: vi.fn(),
29
+ setIntegrationSettings: vi.fn(),
30
+ }
31
+ }
32
+
33
+ type FakeAddonRow = {
34
+ manifest: {
35
+ id: string
36
+ name: string
37
+ description: string
38
+ capabilities: Array<{ name: string }>
39
+ supportsLocationImport?: boolean
40
+ brokerKind?: string
41
+ instanceMode?: string
42
+ icon?: string
43
+ color?: string
44
+ }
45
+ declaration?: {
46
+ supportsLocationImport?: boolean
47
+ brokerKind?: string
48
+ instanceMode?: string
49
+ icon?: string
50
+ color?: string
51
+ }
52
+ process?: { state: string }
53
+ }
54
+
55
+ function makeAddonRegistry(addons: FakeAddonRow[] = []) {
56
+ const integrationReg = makeIntegrationRegistry()
57
+ return {
58
+ getIntegrationRegistry: vi.fn(() => integrationReg),
59
+ listAddons: vi.fn(() => addons),
60
+ restartAddon: vi.fn(),
61
+ getCapabilityRegistry: vi.fn(() => ({
62
+ getProviderByAddon: vi.fn(() => null),
63
+ })),
64
+ }
65
+ }
66
+
67
+ function makeEventBus() {
68
+ return { emit: vi.fn() }
69
+ }
70
+
71
+ function makeLogger() {
72
+ return {
73
+ info: vi.fn(),
74
+ warn: vi.fn(),
75
+ error: vi.fn(),
76
+ }
77
+ }
78
+
79
+ function makeLoggingService() {
80
+ const log = makeLogger()
81
+ return { createLogger: vi.fn(() => log) }
82
+ }
83
+
84
+ // ── Tests ────────────────────────────────────────────────────────────────────
85
+
86
+ describe('getAvailableTypes — supportsLocationImport flag', () => {
87
+ it('returns supportsLocationImport=true for a device-adoption addon that declares it in the manifest', async () => {
88
+ const addon: FakeAddonRow = {
89
+ manifest: {
90
+ id: 'provider-homeassistant',
91
+ name: 'Home Assistant Provider',
92
+ description: 'Adopt HA devices',
93
+ capabilities: [{ name: 'device-adoption' }],
94
+ supportsLocationImport: true,
95
+ brokerKind: 'home-assistant',
96
+ instanceMode: 'multiple',
97
+ },
98
+ }
99
+ const ar = makeAddonRegistry([addon])
100
+ const provider = buildIntegrationsProvider(
101
+ ar as never,
102
+ makeEventBus() as never,
103
+ makeLoggingService() as never,
104
+ null,
105
+ )
106
+ const types = await provider.getAvailableTypes()
107
+
108
+ expect(types).toHaveLength(1)
109
+ expect(types[0]).toMatchObject({
110
+ addonId: 'provider-homeassistant',
111
+ kind: 'device-adoption',
112
+ supportsLocationImport: true,
113
+ })
114
+ })
115
+
116
+ it('returns supportsLocationImport=false for a device-adoption addon that omits the flag', async () => {
117
+ const addon: FakeAddonRow = {
118
+ manifest: {
119
+ id: 'provider-test-adoption',
120
+ name: 'Test Adoption Provider',
121
+ description: 'Adoption without location import',
122
+ capabilities: [{ name: 'device-adoption' }],
123
+ brokerKind: 'test-broker',
124
+ instanceMode: 'multiple',
125
+ },
126
+ }
127
+ const ar = makeAddonRegistry([addon])
128
+ const provider = buildIntegrationsProvider(
129
+ ar as never,
130
+ makeEventBus() as never,
131
+ makeLoggingService() as never,
132
+ null,
133
+ )
134
+ const types = await provider.getAvailableTypes()
135
+
136
+ expect(types).toHaveLength(1)
137
+ expect(types[0]).toMatchObject({
138
+ addonId: 'provider-test-adoption',
139
+ kind: 'device-adoption',
140
+ supportsLocationImport: false,
141
+ })
142
+ })
143
+
144
+ it('returns supportsLocationImport=false for a device-provider addon even when the flag is set on manifest', async () => {
145
+ const addon: FakeAddonRow = {
146
+ manifest: {
147
+ id: 'provider-reolink',
148
+ name: 'Reolink Provider',
149
+ description: 'Classic device provider',
150
+ capabilities: [{ name: 'device-provider' }],
151
+ // Hypothetical: even if someone mistakenly sets this on a device-provider
152
+ supportsLocationImport: true,
153
+ instanceMode: 'single',
154
+ },
155
+ }
156
+ const ar = makeAddonRegistry([addon])
157
+ const provider = buildIntegrationsProvider(
158
+ ar as never,
159
+ makeEventBus() as never,
160
+ makeLoggingService() as never,
161
+ null,
162
+ )
163
+ const types = await provider.getAvailableTypes()
164
+
165
+ expect(types).toHaveLength(1)
166
+ expect(types[0]).toMatchObject({
167
+ addonId: 'provider-reolink',
168
+ kind: 'device-provider',
169
+ supportsLocationImport: false,
170
+ })
171
+ })
172
+
173
+ it('prefers declaration.supportsLocationImport over manifest when declaration is present', async () => {
174
+ const addon: FakeAddonRow = {
175
+ manifest: {
176
+ id: 'provider-homeassistant',
177
+ name: 'Home Assistant Provider',
178
+ description: 'Adopt HA devices',
179
+ capabilities: [{ name: 'device-adoption' }],
180
+ supportsLocationImport: false,
181
+ brokerKind: 'home-assistant',
182
+ instanceMode: 'multiple',
183
+ },
184
+ declaration: {
185
+ supportsLocationImport: true,
186
+ brokerKind: 'home-assistant',
187
+ instanceMode: 'multiple',
188
+ },
189
+ }
190
+ const ar = makeAddonRegistry([addon])
191
+ const provider = buildIntegrationsProvider(
192
+ ar as never,
193
+ makeEventBus() as never,
194
+ makeLoggingService() as never,
195
+ null,
196
+ )
197
+ const types = await provider.getAvailableTypes()
198
+
199
+ expect(types).toHaveLength(1)
200
+ expect(types[0]).toMatchObject({
201
+ addonId: 'provider-homeassistant',
202
+ kind: 'device-adoption',
203
+ supportsLocationImport: true,
204
+ })
205
+ })
206
+ })
@@ -3,13 +3,27 @@ import { __resetCapUsageRegistryForTests, getCapUsageRegistry } from '@camstack/
3
3
  import { buildNodesProvider } from '../../api/core/cap-providers'
4
4
 
5
5
  describe('nodes.getCapUsageGraph provider', () => {
6
- beforeEach(() => { __resetCapUsageRegistryForTests() })
6
+ beforeEach(() => {
7
+ __resetCapUsageRegistryForTests()
8
+ })
7
9
 
8
10
  it('returns recorded edges projected through the cap-usage registry', async () => {
9
11
  const reg = getCapUsageRegistry()
10
12
  const t0 = Date.now() - 5_000
11
- reg.recordCall({ callerAddonId: 'A', providerAddonId: 'B', capName: 'foo', methodName: 'm', atMs: t0 })
12
- reg.recordCall({ callerAddonId: 'A', providerAddonId: 'B', capName: 'foo', methodName: 'm', atMs: t0 + 1000 })
13
+ reg.recordCall({
14
+ callerAddonId: 'A',
15
+ providerAddonId: 'B',
16
+ capName: 'foo',
17
+ methodName: 'm',
18
+ atMs: t0,
19
+ })
20
+ reg.recordCall({
21
+ callerAddonId: 'A',
22
+ providerAddonId: 'B',
23
+ capName: 'foo',
24
+ methodName: 'm',
25
+ atMs: t0 + 1000,
26
+ })
13
27
 
14
28
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
15
29
  const provider = buildNodesProvider({} as any, { broker: { call: vi.fn() } } as any)
@@ -6,7 +6,17 @@ function makeAgentRegistry(): { listNodes: () => Promise<unknown[]> } {
6
6
  return {
7
7
  listNodes: async () => [
8
8
  {
9
- info: { id: 'hub', name: 'hub', hostname: 'hub', platform: 'darwin', arch: 'arm64', cpuModel: 'M2', cpuCores: 8, memoryMB: 16384, pythonRuntimes: [] },
9
+ info: {
10
+ id: 'hub',
11
+ name: 'hub',
12
+ hostname: 'hub',
13
+ platform: 'darwin',
14
+ arch: 'arm64',
15
+ cpuModel: 'M2',
16
+ cpuCores: 8,
17
+ memoryMB: 16384,
18
+ pythonRuntimes: [],
19
+ },
10
20
  status: { cpuPercent: 12, memoryPercent: 30 },
11
21
  isHub: true,
12
22
  isOnline: true,
@@ -22,10 +32,34 @@ function makeAgentRegistry(): { listNodes: () => Promise<unknown[]> } {
22
32
  function makeAddonRegistry(): { listAddons: () => readonly unknown[] } {
23
33
  return {
24
34
  listAddons: () => [
25
- { manifest: { id: 'provider-hikvision' }, declaration: { id: 'provider-hikvision', category: 'providers', capabilities: [{ name: 'stream-params' }] } },
26
- { manifest: { id: 'provider-onvif' }, declaration: { id: 'provider-onvif', category: 'providers', capabilities: [{ name: 'device-provider' }] } },
27
- { manifest: { id: 'stream-broker' }, declaration: { id: 'stream-broker', category: 'pipeline', capabilities: [{ name: 'stream-broker' }] } },
28
- { manifest: { id: 'sqlite-settings' }, declaration: { id: 'sqlite-settings', capabilities: [{ name: 'settings-store' }] } },
35
+ {
36
+ manifest: { id: 'provider-hikvision' },
37
+ declaration: {
38
+ id: 'provider-hikvision',
39
+ category: 'providers',
40
+ capabilities: [{ name: 'stream-params' }],
41
+ },
42
+ },
43
+ {
44
+ manifest: { id: 'provider-onvif' },
45
+ declaration: {
46
+ id: 'provider-onvif',
47
+ category: 'providers',
48
+ capabilities: [{ name: 'device-provider' }],
49
+ },
50
+ },
51
+ {
52
+ manifest: { id: 'stream-broker' },
53
+ declaration: {
54
+ id: 'stream-broker',
55
+ category: 'pipeline',
56
+ capabilities: [{ name: 'stream-broker' }],
57
+ },
58
+ },
59
+ {
60
+ manifest: { id: 'sqlite-settings' },
61
+ declaration: { id: 'sqlite-settings', capabilities: [{ name: 'settings-store' }] },
62
+ },
29
63
  ],
30
64
  }
31
65
  }
@@ -36,12 +70,17 @@ describe('computeTopology categories[] aggregation', () => {
36
70
  const nodes = await computeTopology(makeAgentRegistry() as any, makeAddonRegistry() as any)
37
71
  expect(nodes).toHaveLength(1)
38
72
  const cats = nodes[0]!.categories
39
- const byId = new Map(cats.map(c => [c.category, c]))
73
+ const byId = new Map(cats.map((c) => [c.category, c]))
40
74
  expect(byId.get('providers')?.total).toBe(2)
41
75
  expect(byId.get('providers')?.healthy).toBe(2)
42
- expect(byId.get('providers')?.addons.map(a => a.id).sort()).toEqual(['provider-hikvision', 'provider-onvif'])
76
+ expect(
77
+ byId
78
+ .get('providers')
79
+ ?.addons.map((a) => a.id)
80
+ .toSorted(),
81
+ ).toEqual(['provider-hikvision', 'provider-onvif'])
43
82
  expect(byId.get('pipeline')?.total).toBe(1)
44
- expect(byId.get('system')?.addons.map(a => a.id)).toEqual(['sqlite-settings'])
83
+ expect(byId.get('system')?.addons.map((a) => a.id)).toEqual(['sqlite-settings'])
45
84
  })
46
85
 
47
86
  it('counts a non-running addon as not-healthy', async () => {
@@ -49,13 +88,20 @@ describe('computeTopology categories[] aggregation', () => {
49
88
  // Force the addon registry to return one stopped + one running provider.
50
89
  const addonReg = {
51
90
  listAddons: () => [
52
- { manifest: { id: 'provider-rtsp' }, declaration: { id: 'provider-rtsp', category: 'providers', capabilities: [] } },
53
- { manifest: { id: 'provider-hikvision' }, declaration: { id: 'provider-hikvision', category: 'providers', capabilities: [] }, status: 'failed' },
91
+ {
92
+ manifest: { id: 'provider-rtsp' },
93
+ declaration: { id: 'provider-rtsp', category: 'providers', capabilities: [] },
94
+ },
95
+ {
96
+ manifest: { id: 'provider-hikvision' },
97
+ declaration: { id: 'provider-hikvision', category: 'providers', capabilities: [] },
98
+ status: 'failed',
99
+ },
54
100
  ],
55
101
  }
56
102
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
57
103
  const nodes = await computeTopology(agentReg as any, addonReg as any)
58
- const providers = nodes[0]!.categories.find(c => c.category === 'providers')!
104
+ const providers = nodes[0]!.categories.find((c) => c.category === 'providers')!
59
105
  expect(providers.total).toBe(2)
60
106
  // The current TopologyNode `addons[].status` is always `'running'` per existing code;
61
107
  // failed addons would surface through subProcesses[].state. Document the current contract: