@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
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Routing regression spec for the `broker` system-collection cap.
|
|
3
|
+
*
|
|
4
|
+
* The bug: `broker` is `scope:'system', mode:'collection'`. Two addons
|
|
5
|
+
* register a `broker` provider, each owning a DISJOINT id set
|
|
6
|
+
* (mqtt-broker owns `mqtt_*`, provider-homeassistant owns `ha_*`). The
|
|
7
|
+
* generated collection mount only fans out the array-output methods
|
|
8
|
+
* (`list` / `listProviders`); every id-keyed method (`get` / `getSettings`
|
|
9
|
+
* / `remove` / `testConnection` / …) falls through to `providers[0]`
|
|
10
|
+
* (the FIRST-registered provider = mqtt-broker). So operating on an HA
|
|
11
|
+
* broker `ha_1` hit mqtt-broker → "broker 'ha_1' not found".
|
|
12
|
+
*
|
|
13
|
+
* The fix: each broker carries its owning `addonId`; the admin UI threads
|
|
14
|
+
* it back as the `{ addonId }` system-collection selector so the call
|
|
15
|
+
* routes to the OWNING provider via `getProviderByAddonId`. Providers
|
|
16
|
+
* also return `null` (not throw) for ids they don't own, so the
|
|
17
|
+
* no-addonId fallback degrades gracefully.
|
|
18
|
+
*
|
|
19
|
+
* This spec exercises `createCapRouter_broker` with the SAME selector
|
|
20
|
+
* the generated mount builds (registry-backed: addonId → provider, else
|
|
21
|
+
* first-provider aggregate with array methods fanned out). It registers
|
|
22
|
+
* two mock providers and asserts:
|
|
23
|
+
* - `list()` returns both, each tagged with its `addonId`.
|
|
24
|
+
* - `get({id:'ha_1'}, addonId:'ha')` routes to the HA provider.
|
|
25
|
+
* - `get({id:'ha_1'})` WITHOUT addonId returns null (graceful) — the
|
|
26
|
+
* HA-owned id isn't in providers[0]'s registry, so it doesn't throw.
|
|
27
|
+
* - `listProviders()` aggregates both providers' entries.
|
|
28
|
+
*/
|
|
29
|
+
import { describe, it, expect } from 'vitest'
|
|
30
|
+
import { createCapRouter_broker } from '../../api/trpc/generated-cap-routers.js'
|
|
31
|
+
import { type IBrokerProvider, type BrokerInfo, type BrokerProviderInfo } from '@camstack/types'
|
|
32
|
+
import { concatCollection } from '../../api/trpc/cap-mount-helpers.js'
|
|
33
|
+
import { makeCtx, invokeProcedure } from './harness.js'
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A self-contained mock `broker` provider that owns a fixed set of
|
|
37
|
+
* broker ids and self-tags every returned record with its `addonId`.
|
|
38
|
+
* Returns `null` / no-op for ids it does NOT own (never throws) — the
|
|
39
|
+
* exact graceful-degradation contract the real providers must honour.
|
|
40
|
+
*/
|
|
41
|
+
function makeProvider(addonId: string, ownedIds: readonly string[], kind: string): IBrokerProvider {
|
|
42
|
+
const owns = (id: string): boolean => ownedIds.includes(id)
|
|
43
|
+
const infoFor = (id: string): BrokerInfo => ({
|
|
44
|
+
id,
|
|
45
|
+
addonId,
|
|
46
|
+
name: `${kind}-${id}`,
|
|
47
|
+
kind,
|
|
48
|
+
status: 'connected',
|
|
49
|
+
info: {},
|
|
50
|
+
lastCheckedAt: null,
|
|
51
|
+
error: null,
|
|
52
|
+
})
|
|
53
|
+
return {
|
|
54
|
+
list: async () => ownedIds.map(infoFor),
|
|
55
|
+
get: async ({ id }) => (owns(id) ? infoFor(id) : null),
|
|
56
|
+
listProviders: async (): Promise<BrokerProviderInfo[]> => [
|
|
57
|
+
{ addonId, kinds: [{ kind, label: kind }] },
|
|
58
|
+
],
|
|
59
|
+
add: async () => ({ id: 'new' }),
|
|
60
|
+
remove: async () => {
|
|
61
|
+
// no-op for foreign ids (and owned ids in this mock)
|
|
62
|
+
},
|
|
63
|
+
testConnection: async ({ id }) =>
|
|
64
|
+
owns(id) ? { ok: true, latencyMs: 1 } : { ok: false, error: 'unknown broker' },
|
|
65
|
+
getSettings: async ({ id }) => (owns(id) ? { secret: id } : null),
|
|
66
|
+
setSettings: async () => {
|
|
67
|
+
// no-op
|
|
68
|
+
},
|
|
69
|
+
getBrokerConfig: async ({ id }) => (owns(id) ? { url: id } : null),
|
|
70
|
+
getSettingsSchema: async ({ kind: k }) => (k === kind ? { form: kind } : null),
|
|
71
|
+
testSettings: async () => ({ ok: true }),
|
|
72
|
+
publish: async () => null,
|
|
73
|
+
subscribe: async () => ({ subscriptionId: 's' }),
|
|
74
|
+
unsubscribe: async () => {
|
|
75
|
+
// no-op
|
|
76
|
+
},
|
|
77
|
+
getState: async () => null,
|
|
78
|
+
getStatus: async () => ({ brokerCount: ownedIds.length, connectedCount: ownedIds.length }),
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Mirror of the generated collection mount selector for `broker`
|
|
84
|
+
* (`generated-cap-mounts.ts`): addonId → that provider directly; else a
|
|
85
|
+
* first-provider aggregate whose array-output methods are
|
|
86
|
+
* `concatCollection`-fanned. Two providers registered in order:
|
|
87
|
+
* mqtt (owns `mqtt_1`) first, ha (owns `ha_1`) second.
|
|
88
|
+
*/
|
|
89
|
+
function makeSelector(): (addonId?: string) => IBrokerProvider | null {
|
|
90
|
+
const mqtt = makeProvider('mqtt', ['mqtt_1'], 'mqtt')
|
|
91
|
+
const ha = makeProvider('ha', ['ha_1'], 'home-assistant')
|
|
92
|
+
const byAddonId: Record<string, IBrokerProvider> = { mqtt, ha }
|
|
93
|
+
const providers: readonly IBrokerProvider[] = [mqtt, ha]
|
|
94
|
+
return (addonId?: string): IBrokerProvider | null => {
|
|
95
|
+
if (addonId !== undefined) return byAddonId[addonId] ?? null
|
|
96
|
+
const first = providers[0]!
|
|
97
|
+
return {
|
|
98
|
+
...first,
|
|
99
|
+
list: concatCollection(providers, 'list') as IBrokerProvider['list'],
|
|
100
|
+
listProviders: concatCollection(
|
|
101
|
+
providers,
|
|
102
|
+
'listProviders',
|
|
103
|
+
) as IBrokerProvider['listProviders'],
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
describe('broker cap — addonId ownership routing', () => {
|
|
109
|
+
it('list() aggregates both providers, each broker tagged with its addonId', async () => {
|
|
110
|
+
const selector = makeSelector()
|
|
111
|
+
const router = createCapRouter_broker((_ctx, addonId) => selector(addonId))
|
|
112
|
+
|
|
113
|
+
const outcome = await invokeProcedure(router, 'list', makeCtx('admin'), {})
|
|
114
|
+
|
|
115
|
+
expect(outcome.ok).toBe(true)
|
|
116
|
+
if (!outcome.ok) return
|
|
117
|
+
const rows = outcome.value as BrokerInfo[]
|
|
118
|
+
const byId = new Map(rows.map((r) => [r.id, r]))
|
|
119
|
+
expect(byId.get('mqtt_1')?.addonId).toBe('mqtt')
|
|
120
|
+
expect(byId.get('ha_1')?.addonId).toBe('ha')
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('get({id:ha_1}, addonId:ha) routes to the HA provider (not mqtt-broker)', async () => {
|
|
124
|
+
const selector = makeSelector()
|
|
125
|
+
const router = createCapRouter_broker((_ctx, addonId) => selector(addonId))
|
|
126
|
+
|
|
127
|
+
const outcome = await invokeProcedure(router, 'get', makeCtx('admin'), {
|
|
128
|
+
id: 'ha_1',
|
|
129
|
+
addonId: 'ha',
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
expect(outcome.ok).toBe(true)
|
|
133
|
+
if (!outcome.ok) return
|
|
134
|
+
const row = outcome.value as BrokerInfo | null
|
|
135
|
+
expect(row).not.toBeNull()
|
|
136
|
+
expect(row?.id).toBe('ha_1')
|
|
137
|
+
expect(row?.addonId).toBe('ha')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('get({id:ha_1}) WITHOUT addonId returns null gracefully (first provider does not own it)', async () => {
|
|
141
|
+
const selector = makeSelector()
|
|
142
|
+
const router = createCapRouter_broker((_ctx, addonId) => selector(addonId))
|
|
143
|
+
|
|
144
|
+
const outcome = await invokeProcedure(router, 'get', makeCtx('admin'), { id: 'ha_1' })
|
|
145
|
+
|
|
146
|
+
expect(outcome.ok).toBe(true)
|
|
147
|
+
if (!outcome.ok) return
|
|
148
|
+
expect(outcome.value).toBeNull()
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('testConnection({id:ha_1}, addonId:ha) routes to HA provider and succeeds', async () => {
|
|
152
|
+
const selector = makeSelector()
|
|
153
|
+
const router = createCapRouter_broker((_ctx, addonId) => selector(addonId))
|
|
154
|
+
|
|
155
|
+
const outcome = await invokeProcedure(router, 'testConnection', makeCtx('admin'), {
|
|
156
|
+
id: 'ha_1',
|
|
157
|
+
addonId: 'ha',
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
expect(outcome.ok).toBe(true)
|
|
161
|
+
if (!outcome.ok) return
|
|
162
|
+
expect(outcome.value).toEqual({ ok: true, latencyMs: 1 })
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('listProviders() aggregates both providers entries', async () => {
|
|
166
|
+
const selector = makeSelector()
|
|
167
|
+
const router = createCapRouter_broker((_ctx, addonId) => selector(addonId))
|
|
168
|
+
|
|
169
|
+
const outcome = await invokeProcedure(router, 'listProviders', makeCtx('admin'))
|
|
170
|
+
|
|
171
|
+
expect(outcome.ok).toBe(true)
|
|
172
|
+
if (!outcome.ok) return
|
|
173
|
+
const entries = outcome.value as BrokerProviderInfo[]
|
|
174
|
+
const addonIds = entries.map((e) => e.addonId).toSorted()
|
|
175
|
+
expect(addonIds).toEqual(['ha', 'mqtt'])
|
|
176
|
+
})
|
|
177
|
+
})
|
|
@@ -13,7 +13,9 @@ import { formatTrpcError } from '../../api/trpc/cap-route-error-formatter.js'
|
|
|
13
13
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
14
14
|
|
|
15
15
|
/** Minimal DefaultErrorShape stub. */
|
|
16
|
-
function makeShape(
|
|
16
|
+
function makeShape(
|
|
17
|
+
overrides: Partial<{ message: string }> = {},
|
|
18
|
+
): Parameters<typeof formatTrpcError>[0]['shape'] {
|
|
17
19
|
return {
|
|
18
20
|
message: overrides.message ?? 'Something went wrong',
|
|
19
21
|
code: -32603, // INTERNAL_SERVER_ERROR code number
|
|
@@ -15,14 +15,25 @@ import { createCapabilitiesRouter } from '../../api/core/capabilities.router.js'
|
|
|
15
15
|
import { makeCtx } from './harness.js'
|
|
16
16
|
|
|
17
17
|
function harness() {
|
|
18
|
-
const calls: { setActiveSingleton: unknown[][]; clear: unknown[][] } = {
|
|
18
|
+
const calls: { setActiveSingleton: unknown[][]; clear: unknown[][] } = {
|
|
19
|
+
setActiveSingleton: [],
|
|
20
|
+
clear: [],
|
|
21
|
+
}
|
|
19
22
|
const sets: Record<string, unknown> = {}
|
|
20
23
|
const registry = {
|
|
21
|
-
setActiveSingleton: vi.fn(async (...a: unknown[]) => {
|
|
22
|
-
|
|
24
|
+
setActiveSingleton: vi.fn(async (...a: unknown[]) => {
|
|
25
|
+
calls.setActiveSingleton.push(a)
|
|
26
|
+
}),
|
|
27
|
+
clearSingletonNodeOverride: vi.fn((...a: unknown[]) => {
|
|
28
|
+
calls.clear.push(a)
|
|
29
|
+
}),
|
|
23
30
|
listCapabilities: () => [],
|
|
24
31
|
} as unknown as CapabilityRegistry
|
|
25
|
-
const config = {
|
|
32
|
+
const config = {
|
|
33
|
+
set: (k: string, v: unknown) => {
|
|
34
|
+
sets[k] = v
|
|
35
|
+
},
|
|
36
|
+
} as unknown as ConfigService
|
|
26
37
|
const router = createCapabilitiesRouter(registry, config)
|
|
27
38
|
return { router, calls, sets, registry }
|
|
28
39
|
}
|
|
@@ -32,7 +43,9 @@ describe('capabilities.router — per-node singleton', () => {
|
|
|
32
43
|
const { router, calls, sets } = harness()
|
|
33
44
|
const caller = router.createCaller(makeCtx('admin'))
|
|
34
45
|
await caller.setActiveSingleton({
|
|
35
|
-
capability: 'webrtc-session',
|
|
46
|
+
capability: 'webrtc-session',
|
|
47
|
+
addonId: 'webrtc-native',
|
|
48
|
+
nodeId: 'dev-agent-0',
|
|
36
49
|
})
|
|
37
50
|
expect(calls.setActiveSingleton[0]).toEqual(['webrtc-session', 'webrtc-native', 'dev-agent-0'])
|
|
38
51
|
expect(sets['capabilities.singletonNode.webrtc-session.dev-agent-0']).toBe('webrtc-native')
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests that `requireDeviceScoped` overlays `getStatus` results via
|
|
3
|
+
* `device-manager`'s `resolveLinkedStatus` when the device has linked
|
|
4
|
+
* properties for the requested cap.
|
|
5
|
+
*
|
|
6
|
+
* The overlay is transparent (null return = no-op) for the vast majority
|
|
7
|
+
* of reads; only devices with active links for the cap get the merged
|
|
8
|
+
* status back.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
11
|
+
import type { CapabilityRegistry } from '@camstack/kernel'
|
|
12
|
+
import { requireDeviceScoped } from '../../api/trpc/cap-mount-helpers.js'
|
|
13
|
+
|
|
14
|
+
// ── Fake registry ────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
type ResolveLinkedStatus = (i: {
|
|
17
|
+
deviceId: number
|
|
18
|
+
cap: string
|
|
19
|
+
baseStatus: unknown
|
|
20
|
+
}) => Promise<Record<string, unknown> | null>
|
|
21
|
+
|
|
22
|
+
interface FakeDeviceManager {
|
|
23
|
+
resolveLinkedStatus: ResolveLinkedStatus
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function makeRegistry(opts: {
|
|
27
|
+
nativeGetStatus: () => Promise<Record<string, unknown>>
|
|
28
|
+
nativeSetFanSpeed?: (i: unknown) => Promise<void>
|
|
29
|
+
resolveLinkedStatus: ResolveLinkedStatus
|
|
30
|
+
}): CapabilityRegistry {
|
|
31
|
+
const nativeProvider = {
|
|
32
|
+
getStatus: opts.nativeGetStatus,
|
|
33
|
+
setFanSpeed: opts.nativeSetFanSpeed ?? vi.fn(async () => undefined),
|
|
34
|
+
}
|
|
35
|
+
const deviceManager: FakeDeviceManager = {
|
|
36
|
+
resolveLinkedStatus: opts.resolveLinkedStatus,
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
getNativeProvider<T>(_capName: string, _deviceId: number): T | null {
|
|
40
|
+
return nativeProvider as unknown as T
|
|
41
|
+
},
|
|
42
|
+
getSingleton<T>(capability: string): T | null {
|
|
43
|
+
if (capability === 'device-manager') {
|
|
44
|
+
return deviceManager as unknown as T
|
|
45
|
+
}
|
|
46
|
+
return null
|
|
47
|
+
},
|
|
48
|
+
// Minimal no-op stubs for the rest of the CapabilityRegistry surface
|
|
49
|
+
// so TypeScript is satisfied without pulling in the real kernel class.
|
|
50
|
+
listCapabilities: vi.fn(() => []),
|
|
51
|
+
registerProvider: vi.fn(),
|
|
52
|
+
unregisterProvider: vi.fn(),
|
|
53
|
+
getCollection: vi.fn(() => []),
|
|
54
|
+
getCollectionEntries: vi.fn(() => []),
|
|
55
|
+
registerNativeProvider: vi.fn(),
|
|
56
|
+
unregisterNativeProvider: vi.fn(),
|
|
57
|
+
getProviderForDevice: vi.fn(() => null),
|
|
58
|
+
getBindings: vi.fn(() => ({ entries: [] })),
|
|
59
|
+
setActiveSingleton: vi.fn(),
|
|
60
|
+
getSingletonAddonId: vi.fn(() => null),
|
|
61
|
+
getAddonIdForProvider: vi.fn(() => null),
|
|
62
|
+
on: vi.fn(),
|
|
63
|
+
off: vi.fn(),
|
|
64
|
+
dispose: vi.fn(),
|
|
65
|
+
} as unknown as CapabilityRegistry
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── Tests ────────────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
const DEVICE_ID = 668
|
|
71
|
+
const CAP_NAME = 'vacuum-control' as Parameters<typeof requireDeviceScoped>[1]
|
|
72
|
+
|
|
73
|
+
describe('requireDeviceScoped — getStatus overlay via resolveLinkedStatus', () => {
|
|
74
|
+
it('returns the overlaid status when resolveLinkedStatus returns a non-null object', async () => {
|
|
75
|
+
const base = { state: 'idle', battery: 80 }
|
|
76
|
+
const overlaid = { state: 'paused', cleanWater: { status: 'low', level: null } }
|
|
77
|
+
|
|
78
|
+
const registry = makeRegistry({
|
|
79
|
+
nativeGetStatus: vi.fn(async () => base),
|
|
80
|
+
resolveLinkedStatus: vi.fn(async () => overlaid),
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const dispatcher = requireDeviceScoped(registry, CAP_NAME)
|
|
84
|
+
expect(dispatcher).not.toBeNull()
|
|
85
|
+
|
|
86
|
+
// Call via the Proxy — method is resolved lazily
|
|
87
|
+
const result = await (
|
|
88
|
+
dispatcher as unknown as { getStatus: (i: { deviceId: number }) => Promise<unknown> }
|
|
89
|
+
).getStatus({ deviceId: DEVICE_ID })
|
|
90
|
+
|
|
91
|
+
expect(result).toEqual(overlaid)
|
|
92
|
+
expect(result).not.toEqual(base)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('returns the base provider result unchanged when resolveLinkedStatus returns null (no links)', async () => {
|
|
96
|
+
const base = { state: 'cleaning', battery: 60 }
|
|
97
|
+
|
|
98
|
+
const registry = makeRegistry({
|
|
99
|
+
nativeGetStatus: vi.fn(async () => base),
|
|
100
|
+
resolveLinkedStatus: vi.fn(async () => null),
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
const dispatcher = requireDeviceScoped(registry, CAP_NAME)
|
|
104
|
+
expect(dispatcher).not.toBeNull()
|
|
105
|
+
|
|
106
|
+
const result = await (
|
|
107
|
+
dispatcher as unknown as { getStatus: (i: { deviceId: number }) => Promise<unknown> }
|
|
108
|
+
).getStatus({ deviceId: DEVICE_ID })
|
|
109
|
+
|
|
110
|
+
expect(result).toEqual(base)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('does NOT call resolveLinkedStatus for non-getStatus methods', async () => {
|
|
114
|
+
const base = { state: 'idle', battery: 90 }
|
|
115
|
+
const resolveLinkedStatus = vi.fn(async () => null)
|
|
116
|
+
const nativeSetFanSpeed = vi.fn(async () => undefined)
|
|
117
|
+
|
|
118
|
+
const registry = makeRegistry({
|
|
119
|
+
nativeGetStatus: vi.fn(async () => base),
|
|
120
|
+
nativeSetFanSpeed,
|
|
121
|
+
resolveLinkedStatus,
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
const dispatcher = requireDeviceScoped(registry, CAP_NAME)
|
|
125
|
+
expect(dispatcher).not.toBeNull()
|
|
126
|
+
|
|
127
|
+
await (
|
|
128
|
+
dispatcher as unknown as {
|
|
129
|
+
setFanSpeed: (i: { deviceId: number; speed: string }) => Promise<void>
|
|
130
|
+
}
|
|
131
|
+
).setFanSpeed({ deviceId: DEVICE_ID, speed: 'high' })
|
|
132
|
+
|
|
133
|
+
expect(nativeSetFanSpeed).toHaveBeenCalledOnce()
|
|
134
|
+
// The overlay path must NOT be consulted for mutations
|
|
135
|
+
expect(resolveLinkedStatus).not.toHaveBeenCalled()
|
|
136
|
+
})
|
|
137
|
+
})
|
|
@@ -24,25 +24,54 @@ function makeProvider(): IDeviceManagerProvider {
|
|
|
24
24
|
title: 'Identity',
|
|
25
25
|
tab: 'general',
|
|
26
26
|
order: 0,
|
|
27
|
-
fields: [
|
|
27
|
+
fields: [
|
|
28
|
+
{
|
|
29
|
+
type: 'text',
|
|
30
|
+
key: '_stableId',
|
|
31
|
+
label: 'Stable ID',
|
|
32
|
+
readonlyField: true,
|
|
33
|
+
value: String(deviceId),
|
|
34
|
+
},
|
|
35
|
+
],
|
|
28
36
|
},
|
|
29
37
|
{
|
|
30
38
|
id: 'motion-tuning',
|
|
31
39
|
title: 'Motion Tuning',
|
|
32
40
|
tab: 'detection',
|
|
33
41
|
order: 5,
|
|
34
|
-
fields: [
|
|
42
|
+
fields: [
|
|
43
|
+
{
|
|
44
|
+
type: 'number',
|
|
45
|
+
key: 'threshold',
|
|
46
|
+
label: 'Threshold',
|
|
47
|
+
writerCapName: 'motion-detection',
|
|
48
|
+
writerAddonId: 'motion-wasm',
|
|
49
|
+
source: 'settings',
|
|
50
|
+
value: 20,
|
|
51
|
+
},
|
|
52
|
+
],
|
|
35
53
|
},
|
|
36
54
|
],
|
|
37
55
|
})),
|
|
38
56
|
getDeviceLiveInfoAggregate: vi.fn(async ({ deviceId }) => ({
|
|
39
|
-
sections: [
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
57
|
+
sections: [
|
|
58
|
+
{
|
|
59
|
+
id: 'orchestrator-live',
|
|
60
|
+
title: 'Pipeline Status',
|
|
61
|
+
tab: 'detection',
|
|
62
|
+
order: 100,
|
|
63
|
+
fields: [
|
|
64
|
+
{
|
|
65
|
+
type: 'text',
|
|
66
|
+
key: 'assignedRunner',
|
|
67
|
+
label: 'Assigned Runner',
|
|
68
|
+
readonlyField: true,
|
|
69
|
+
source: 'live',
|
|
70
|
+
value: deviceId === 1 ? 'hub' : '',
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
],
|
|
46
75
|
})),
|
|
47
76
|
updateDeviceField: vi.fn(async () => ({ success: true as const })),
|
|
48
77
|
|
|
@@ -60,14 +89,21 @@ function makeProvider(): IDeviceManagerProvider {
|
|
|
60
89
|
getStreamSources: vi.fn(async () => []) as IDeviceManagerProvider['getStreamSources'],
|
|
61
90
|
getConfigSchema: vi.fn(async () => []) as IDeviceManagerProvider['getConfigSchema'],
|
|
62
91
|
getSettingsSchema: vi.fn(async () => null) as IDeviceManagerProvider['getSettingsSchema'],
|
|
63
|
-
updateConfig: vi.fn(async () => ({
|
|
92
|
+
updateConfig: vi.fn(async () => ({
|
|
93
|
+
success: true as const,
|
|
94
|
+
})) as IDeviceManagerProvider['updateConfig'],
|
|
64
95
|
enable: vi.fn(async () => ({ success: true as const })) as IDeviceManagerProvider['enable'],
|
|
65
96
|
disable: vi.fn(async () => ({ success: true as const })) as IDeviceManagerProvider['disable'],
|
|
66
97
|
remove: vi.fn(async () => ({ success: true as const })) as IDeviceManagerProvider['remove'],
|
|
67
98
|
getStreamProfileMap: vi.fn(async () => ({})) as IDeviceManagerProvider['getStreamProfileMap'],
|
|
68
|
-
setStreamProfileMap: vi.fn(async () => ({
|
|
99
|
+
setStreamProfileMap: vi.fn(async () => ({
|
|
100
|
+
success: true as const,
|
|
101
|
+
})) as IDeviceManagerProvider['setStreamProfileMap'],
|
|
69
102
|
probeStreams: vi.fn(async () => []) as IDeviceManagerProvider['probeStreams'],
|
|
70
|
-
getBindings: vi.fn(async ({ deviceId }: { deviceId: number }) => ({
|
|
103
|
+
getBindings: vi.fn(async ({ deviceId }: { deviceId: number }) => ({
|
|
104
|
+
deviceId,
|
|
105
|
+
entries: [],
|
|
106
|
+
})) as IDeviceManagerProvider['getBindings'],
|
|
71
107
|
setWrapperActive: vi.fn() as IDeviceManagerProvider['setWrapperActive'],
|
|
72
108
|
}
|
|
73
109
|
}
|
|
@@ -78,24 +114,32 @@ describe('device-manager aggregator via tRPC router', () => {
|
|
|
78
114
|
it('getDeviceSettingsAggregate returns the merged shape for a known device', async () => {
|
|
79
115
|
const provider = makeProvider()
|
|
80
116
|
const router = createCapRouter_deviceManager(() => provider)
|
|
81
|
-
const result = await invokeProcedure(router, 'getDeviceSettingsAggregate', makeCtx('admin'), {
|
|
117
|
+
const result = await invokeProcedure(router, 'getDeviceSettingsAggregate', makeCtx('admin'), {
|
|
118
|
+
deviceId: DEVICE_ID,
|
|
119
|
+
})
|
|
82
120
|
|
|
83
121
|
expect(result.ok).toBe(true)
|
|
84
122
|
if (!result.ok) return
|
|
85
|
-
const value = result.value as {
|
|
86
|
-
|
|
123
|
+
const value = result.value as {
|
|
124
|
+
sections: readonly { id: string; tab?: string; fields: readonly unknown[] }[]
|
|
125
|
+
}
|
|
126
|
+
expect(value.sections.map((s) => s.id)).toEqual(['identity', 'motion-tuning'])
|
|
87
127
|
expect(provider.getDeviceSettingsAggregate).toHaveBeenCalledWith({ deviceId: DEVICE_ID })
|
|
88
128
|
})
|
|
89
129
|
|
|
90
130
|
it('getDeviceLiveInfoAggregate returns live sections', async () => {
|
|
91
131
|
const provider = makeProvider()
|
|
92
132
|
const router = createCapRouter_deviceManager(() => provider)
|
|
93
|
-
const result = await invokeProcedure(router, 'getDeviceLiveInfoAggregate', makeCtx('admin'), {
|
|
133
|
+
const result = await invokeProcedure(router, 'getDeviceLiveInfoAggregate', makeCtx('admin'), {
|
|
134
|
+
deviceId: DEVICE_ID,
|
|
135
|
+
})
|
|
94
136
|
|
|
95
137
|
expect(result.ok).toBe(true)
|
|
96
138
|
if (!result.ok) return
|
|
97
|
-
const value = result.value as {
|
|
98
|
-
|
|
139
|
+
const value = result.value as {
|
|
140
|
+
sections: readonly { fields: readonly { key?: string; value?: unknown }[] }[]
|
|
141
|
+
}
|
|
142
|
+
const runner = value.sections[0]!.fields.find((f) => f.key === 'assignedRunner')
|
|
99
143
|
expect(runner?.value).toBe('hub')
|
|
100
144
|
})
|
|
101
145
|
|
|
@@ -124,7 +168,13 @@ describe('device-manager aggregator via tRPC router', () => {
|
|
|
124
168
|
const provider = makeProvider()
|
|
125
169
|
const router = createCapRouter_deviceManager(() => provider)
|
|
126
170
|
|
|
127
|
-
const payload = {
|
|
171
|
+
const payload = {
|
|
172
|
+
deviceId: DEVICE_ID,
|
|
173
|
+
writerCapName: 'motion-detection',
|
|
174
|
+
writerAddonId: 'motion-wasm',
|
|
175
|
+
key: 'threshold',
|
|
176
|
+
value: 1,
|
|
177
|
+
}
|
|
128
178
|
const viewer = await invokeProcedure(router, 'updateDeviceField', makeCtx('user'), payload)
|
|
129
179
|
expect(viewer.ok).toBe(false)
|
|
130
180
|
if (!viewer.ok) expect(viewer.code).toBe('FORBIDDEN')
|
|
@@ -135,7 +185,9 @@ describe('device-manager aggregator via tRPC router', () => {
|
|
|
135
185
|
|
|
136
186
|
it('router returns PRECONDITION_FAILED when the provider is missing', async () => {
|
|
137
187
|
const router = createCapRouter_deviceManager(() => null)
|
|
138
|
-
const result = await invokeProcedure(router, 'getDeviceSettingsAggregate', makeCtx('admin'), {
|
|
188
|
+
const result = await invokeProcedure(router, 'getDeviceSettingsAggregate', makeCtx('admin'), {
|
|
189
|
+
deviceId: DEVICE_ID,
|
|
190
|
+
})
|
|
139
191
|
expect(result.ok).toBe(false)
|
|
140
192
|
if (!result.ok) expect(result.code).toBe('PRECONDITION_FAILED')
|
|
141
193
|
})
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
|
|
2
1
|
/**
|
|
3
2
|
* Test harness for codegen'd capability tRPC routers.
|
|
4
3
|
*
|
|
@@ -24,7 +23,10 @@ export type AuthLevel = 'public' | 'protected' | 'admin'
|
|
|
24
23
|
export type TestRole = 'anonymous' | 'user' | 'admin' | 'agent'
|
|
25
24
|
|
|
26
25
|
/** Build an AuthenticatedAgent stub for a given synthetic identity. */
|
|
27
|
-
export function makeUser(
|
|
26
|
+
export function makeUser(
|
|
27
|
+
role: Exclude<TestRole, 'anonymous'>,
|
|
28
|
+
overrides: Partial<AuthenticatedAgent> = {},
|
|
29
|
+
): AuthenticatedAgent {
|
|
28
30
|
const isAdmin = role === 'admin' || role === 'agent'
|
|
29
31
|
const base: AuthenticatedAgent = {
|
|
30
32
|
id: `user-${role}`,
|
|
@@ -111,11 +113,13 @@ export async function checkAuthMatrix(
|
|
|
111
113
|
procedure: string,
|
|
112
114
|
auth: AuthLevel,
|
|
113
115
|
input?: unknown,
|
|
114
|
-
): Promise<
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
116
|
+
): Promise<
|
|
117
|
+
ReadonlyArray<{
|
|
118
|
+
readonly role: TestRole
|
|
119
|
+
readonly allowed: boolean
|
|
120
|
+
readonly outcome: Awaited<ReturnType<typeof invokeProcedure>>
|
|
121
|
+
}>
|
|
122
|
+
> {
|
|
119
123
|
const results: Array<{
|
|
120
124
|
role: TestRole
|
|
121
125
|
allowed: boolean
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
|
|
2
1
|
/**
|
|
3
2
|
* Example spec exercising the codegen'd metrics-provider router end-to-end:
|
|
4
3
|
* - Wires a mock provider into `createCapRouter_metricsProvider`
|
|
@@ -14,9 +13,24 @@ function makeMockProvider(overrides: Partial<IMetricsProvider> = {}): IMetricsPr
|
|
|
14
13
|
return {
|
|
15
14
|
collectSnapshot: async () => ({
|
|
16
15
|
cpu: { total: 0, user: 0, system: 0, irq: 0, nice: 0, loadAvg: [0, 0, 0], cores: 1 },
|
|
17
|
-
memory: {
|
|
16
|
+
memory: {
|
|
17
|
+
percent: 0,
|
|
18
|
+
totalBytes: 0,
|
|
19
|
+
usedBytes: 0,
|
|
20
|
+
availableBytes: 0,
|
|
21
|
+
swapUsedBytes: 0,
|
|
22
|
+
swapTotalBytes: 0,
|
|
23
|
+
},
|
|
18
24
|
gpu: null,
|
|
19
|
-
network: {
|
|
25
|
+
network: {
|
|
26
|
+
rxBytes: 0,
|
|
27
|
+
txBytes: 0,
|
|
28
|
+
rxPackets: 0,
|
|
29
|
+
txPackets: 0,
|
|
30
|
+
rxErrors: 0,
|
|
31
|
+
txErrors: 0,
|
|
32
|
+
timestampMs: 0,
|
|
33
|
+
},
|
|
20
34
|
disk: { readBytes: 0, writeBytes: 0, readOps: 0, writeOps: 0, timestampMs: 0 },
|
|
21
35
|
pressure: { cpu: null, memory: null, io: null },
|
|
22
36
|
process: { openFds: 0, threadCount: 0, activeHandles: 0, activeRequests: 0 },
|
|
@@ -36,11 +36,12 @@ describe('null-provider guard — all capabilities', () => {
|
|
|
36
36
|
const router = factory(() => null)
|
|
37
37
|
|
|
38
38
|
// Find the first method on the router's caller
|
|
39
|
-
const caller = (
|
|
40
|
-
|
|
39
|
+
const caller = (
|
|
40
|
+
router as { createCaller: (ctx: unknown) => Record<string, unknown> }
|
|
41
|
+
).createCaller(makeCtx('admin'))
|
|
41
42
|
|
|
42
43
|
const methods = Object.keys(caller).filter(
|
|
43
|
-
k => typeof caller[k] === 'function' && !k.startsWith('_'),
|
|
44
|
+
(k) => typeof caller[k] === 'function' && !k.startsWith('_'),
|
|
44
45
|
)
|
|
45
46
|
|
|
46
47
|
if (methods.length === 0) return // No methods — skip
|
|
@@ -57,10 +58,7 @@ describe('null-provider guard — all capabilities', () => {
|
|
|
57
58
|
// caught by codegen guard) or BAD_REQUEST (input validation before guard)
|
|
58
59
|
// or INTERNAL_SERVER_ERROR (provider method call on null).
|
|
59
60
|
// Any of these is acceptable — the important thing is ok !== true.
|
|
60
|
-
expect(
|
|
61
|
-
result.ok,
|
|
62
|
-
`${name}.${firstMethod} returned success with null provider!`,
|
|
63
|
-
).toBe(false)
|
|
61
|
+
expect(result.ok, `${name}.${firstMethod} returned success with null provider!`).toBe(false)
|
|
64
62
|
})
|
|
65
63
|
}
|
|
66
64
|
})
|