@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.
- package/package.json +11 -9
- package/src/__tests__/addon-install-e2e.test.ts +0 -1
- package/src/__tests__/addon-pages-e2e.test.ts +40 -18
- package/src/__tests__/addon-settings-router.spec.ts +6 -1
- package/src/__tests__/addon-upload.spec.ts +91 -29
- package/src/__tests__/agent-registry.spec.ts +26 -9
- package/src/__tests__/agent-status-page.spec.ts +1 -3
- package/src/__tests__/auth-session-cookie.test.ts +28 -1
- package/src/__tests__/bulk-update-coordinator.spec.ts +48 -31
- package/src/__tests__/cap-ownership-authority.spec.ts +39 -8
- package/src/__tests__/cap-providers/cap-providers-location-import.spec.ts +206 -0
- package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +17 -3
- package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +57 -11
- package/src/__tests__/cap-providers/integrations-delete-cascade.spec.ts +292 -0
- package/src/__tests__/cap-providers-bulk-update.spec.ts +27 -7
- package/src/__tests__/cap-route-adapter.spec.ts +28 -15
- package/src/__tests__/cap-routers/_meta.spec.ts +6 -7
- package/src/__tests__/cap-routers/addon-settings.router.spec.ts +19 -10
- package/src/__tests__/cap-routers/broker-routing.router.spec.ts +177 -0
- package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +3 -1
- package/src/__tests__/cap-routers/capabilities-node.spec.ts +18 -5
- package/src/__tests__/cap-routers/device-link-overlay.spec.ts +137 -0
- package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +72 -20
- package/src/__tests__/cap-routers/harness.ts +11 -7
- package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +17 -3
- package/src/__tests__/cap-routers/null-provider-guard.spec.ts +5 -7
- package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +35 -11
- package/src/__tests__/cap-routers/settings-store.router.spec.ts +59 -15
- package/src/__tests__/capability-e2e.test.ts +9 -11
- package/src/__tests__/cli-e2e.test.ts +80 -59
- package/src/__tests__/core-cap-bridge.spec.ts +3 -1
- package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +12 -2
- package/src/__tests__/device-settings-contribution-dispatch.spec.ts +61 -30
- package/src/__tests__/embedded-deps-e2e.test.ts +35 -19
- package/src/__tests__/event-bus-proxy-router.spec.ts +3 -0
- package/src/__tests__/framework-allowlist.spec.ts +5 -4
- package/src/__tests__/https-e2e.test.ts +12 -6
- package/src/__tests__/lifecycle-e2e.test.ts +60 -11
- package/src/__tests__/live-events-subscription.spec.ts +17 -18
- package/src/__tests__/moleculer/uds-readiness.spec.ts +11 -4
- package/src/__tests__/moleculer/uds-topology.spec.ts +39 -11
- package/src/__tests__/moleculer/uds-unowned-call.spec.ts +265 -5
- package/src/__tests__/moleculer-register-node-idempotency.spec.ts +16 -7
- package/src/__tests__/native-cap-route.spec.ts +42 -19
- package/src/__tests__/oauth2-account-linking.spec.ts +63 -17
- package/src/__tests__/singleton-contention.test.ts +23 -11
- package/src/__tests__/streaming-diagnostic.test.ts +156 -53
- package/src/__tests__/streaming-scale.test.ts +69 -35
- package/src/__tests__/uds-addon-call-wiring.spec.ts +6 -1
- package/src/agent-status-page.ts +4 -3
- package/src/api/__tests__/addons-custom.spec.ts +22 -8
- package/src/api/__tests__/capabilities.router.test.ts +18 -9
- package/src/api/addon-upload.ts +46 -15
- package/src/api/addons-custom.router.ts +7 -6
- package/src/api/auth-whoami.ts +3 -1
- package/src/api/bridge-addons.router.ts +3 -1
- package/src/api/capabilities.router.ts +117 -78
- package/src/api/core/__tests__/auth-router-totp.spec.ts +57 -16
- package/src/api/core/__tests__/integration-markers.spec.ts +10 -0
- package/src/api/core/addon-settings.router.ts +4 -1
- package/src/api/core/agents.router.ts +52 -53
- package/src/api/core/auth.router.ts +55 -36
- package/src/api/core/bulk-update-coordinator.ts +25 -22
- package/src/api/core/cap-providers.ts +459 -166
- package/src/api/core/capabilities.router.ts +30 -23
- package/src/api/core/hwaccel.router.ts +37 -10
- package/src/api/core/live-events.router.ts +16 -9
- package/src/api/core/logs.router.ts +58 -25
- package/src/api/core/notifications.router.ts +2 -1
- package/src/api/core/repl.router.ts +1 -3
- package/src/api/core/settings-backend.router.ts +68 -70
- package/src/api/core/system-events.router.ts +41 -32
- package/src/api/health/health.routes.ts +7 -13
- package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +12 -2
- package/src/api/oauth2/consent-page.ts +4 -3
- package/src/api/oauth2/oauth2-routes.ts +41 -12
- package/src/api/trpc/__tests__/client-ip.spec.ts +27 -1
- package/src/api/trpc/__tests__/scope-access-device.spec.ts +68 -23
- package/src/api/trpc/__tests__/scope-access.spec.ts +8 -13
- package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +136 -0
- package/src/api/trpc/cap-mount-helpers.ts +64 -44
- package/src/api/trpc/cap-route-error-formatter.ts +17 -9
- package/src/api/trpc/client-ip.ts +17 -0
- package/src/api/trpc/core-cap-bridge.ts +3 -1
- package/src/api/trpc/generated-cap-mounts.ts +801 -286
- package/src/api/trpc/generated-cap-routers.ts +5723 -719
- package/src/api/trpc/scope-access.ts +7 -7
- package/src/api/trpc/trpc.context.ts +7 -4
- package/src/api/trpc/trpc.middleware.ts +4 -2
- package/src/api/trpc/trpc.router.ts +117 -48
- package/src/auth/session-cookie.ts +10 -0
- package/src/boot/__tests__/integration-id-backfill.spec.ts +131 -0
- package/src/boot/boot-config.ts +103 -122
- package/src/boot/integration-id-backfill.ts +109 -0
- package/src/boot/post-boot.service.ts +5 -3
- package/src/core/addon/__tests__/addon-registry-capability.test.ts +12 -3
- package/src/core/addon/__tests__/addon-row-manifest.spec.ts +62 -0
- package/src/core/addon/addon-call-gateway.ts +20 -6
- package/src/core/addon/addon-package.service.ts +183 -89
- package/src/core/addon/addon-registry.service.ts +1212 -1267
- package/src/core/addon/addon-row-manifest.ts +29 -0
- package/src/core/addon/addon-search.service.ts +2 -1
- package/src/core/addon/addon-settings-provider.ts +27 -7
- package/src/core/addon-bridge/addon-bridge.service.ts +11 -6
- package/src/core/addon-pages/addon-pages.service.ts +3 -1
- package/src/core/addon-widgets/addon-widgets.service.ts +5 -2
- package/src/core/agent/agent-registry.service.ts +60 -38
- package/src/core/auth/auth.service.spec.ts +6 -8
- package/src/core/config/config.service.spec.ts +1 -1
- package/src/core/events/event-bus.service.spec.ts +44 -21
- package/src/core/events/event-bus.service.ts +5 -1
- package/src/core/feature/feature.service.spec.ts +4 -1
- package/src/core/lifecycle/lifecycle-state-machine.spec.ts +8 -10
- package/src/core/logging/logging.service.spec.ts +61 -21
- package/src/core/logging/logging.service.ts +19 -5
- package/src/core/moleculer/cap-call-fn.spec.ts +17 -10
- package/src/core/moleculer/cap-call-fn.ts +5 -1
- package/src/core/moleculer/cap-route-authority.ts +18 -6
- package/src/core/moleculer/moleculer.service.ts +145 -29
- package/src/core/network/network-quality.service.spec.ts +7 -1
- package/src/core/notification/notification-wrapper.service.ts +1 -3
- package/src/core/notification/toast-wrapper.service.ts +1 -5
- package/src/core/repl/repl-engine.service.spec.ts +66 -39
- package/src/core/repl/repl-engine.service.ts +11 -12
- package/src/core/storage/storage-location-manager.spec.ts +12 -3
- package/src/core/streaming/stream-probe.service.ts +22 -13
- package/src/core/topology/topology-emitter.service.ts +5 -1
- package/src/launcher.ts +14 -9
- package/src/main.ts +658 -495
- package/src/manual-boot.ts +133 -154
- package/tsconfig.json +20 -8
- package/src/core/storage/settings-store.spec.ts +0 -213
- package/src/core/storage/settings-store.ts +0 -2
- package/src/core/storage/sql-schema.spec.ts +0 -140
- 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(
|
|
26
|
-
|
|
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: {
|
|
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(() => {
|
|
59
|
-
|
|
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(
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const lastRegularIdx = updatingNames.findLastIndex(
|
|
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 => {
|
|
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 => {
|
|
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(() =>
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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 {
|
|
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: (
|
|
44
|
-
|
|
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(
|
|
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
|
-
{
|
|
187
|
+
{
|
|
188
|
+
nodeId: 'hub/provider-reolink',
|
|
189
|
+
addonId: 'addon-provider-reolink',
|
|
190
|
+
capName: 'ptz',
|
|
191
|
+
deviceId: 7,
|
|
192
|
+
},
|
|
175
193
|
]
|
|
176
|
-
const nodeAuthority = makeNodeAuthority(
|
|
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
|
-
{
|
|
236
|
+
{
|
|
237
|
+
nodeId: 'hub/provider-reolink',
|
|
238
|
+
addonId: 'addon-provider-reolink',
|
|
239
|
+
capName: 'ptz',
|
|
240
|
+
deviceId: 7,
|
|
241
|
+
},
|
|
215
242
|
]
|
|
216
|
-
const nodeAuthority = makeNodeAuthority(
|
|
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(() => {
|
|
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({
|
|
12
|
-
|
|
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: {
|
|
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
|
-
{
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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(
|
|
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
|
-
{
|
|
53
|
-
|
|
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:
|