@camstack/server 0.1.3
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/.env.example +17 -0
- package/package.json +55 -0
- package/src/__tests__/addon-install-e2e.test.ts +75 -0
- package/src/__tests__/addon-pages-e2e.test.ts +178 -0
- package/src/__tests__/addon-route-session.test.ts +17 -0
- package/src/__tests__/addon-settings-router.spec.ts +62 -0
- package/src/__tests__/addon-upload.spec.ts +355 -0
- package/src/__tests__/agent-registry.spec.ts +162 -0
- package/src/__tests__/agent-status-page.spec.ts +84 -0
- package/src/__tests__/auth-session-cookie.test.ts +21 -0
- package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +23 -0
- package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +64 -0
- package/src/__tests__/cap-routers/_meta.spec.ts +200 -0
- package/src/__tests__/cap-routers/addon-settings.router.spec.ts +106 -0
- package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +142 -0
- package/src/__tests__/cap-routers/harness.ts +159 -0
- package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +119 -0
- package/src/__tests__/cap-routers/null-provider-guard.spec.ts +66 -0
- package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +135 -0
- package/src/__tests__/cap-routers/settings-store.router.spec.ts +247 -0
- package/src/__tests__/capability-e2e.test.ts +386 -0
- package/src/__tests__/cli-e2e.test.ts +129 -0
- package/src/__tests__/core-cap-bridge.spec.ts +89 -0
- package/src/__tests__/embedded-deps-e2e.test.ts +109 -0
- package/src/__tests__/event-bus-proxy-router.spec.ts +72 -0
- package/src/__tests__/fixtures/mock-analysis-addon-a.ts +37 -0
- package/src/__tests__/fixtures/mock-analysis-addon-b.ts +37 -0
- package/src/__tests__/fixtures/mock-log-addon.ts +37 -0
- package/src/__tests__/fixtures/mock-storage-addon.ts +40 -0
- package/src/__tests__/framework-allowlist.spec.ts +95 -0
- package/src/__tests__/https-e2e.test.ts +118 -0
- package/src/__tests__/lifecycle-e2e.test.ts +140 -0
- package/src/__tests__/live-events-subscription.spec.ts +150 -0
- package/src/__tests__/moleculer-register-node-idempotency.spec.ts +229 -0
- package/src/__tests__/oauth2-account-linking.spec.ts +736 -0
- package/src/__tests__/post-boot-restart.spec.ts +161 -0
- package/src/__tests__/singleton-contention.test.ts +487 -0
- package/src/__tests__/streaming-diagnostic.test.ts +512 -0
- package/src/__tests__/streaming-scale.test.ts +280 -0
- package/src/agent-status-page.ts +121 -0
- package/src/api/__tests__/addons-custom.spec.ts +134 -0
- package/src/api/__tests__/capabilities.router.test.ts +47 -0
- package/src/api/addon-upload.ts +472 -0
- package/src/api/addons-custom.router.ts +100 -0
- package/src/api/auth-whoami.ts +99 -0
- package/src/api/bridge-addons.router.ts +120 -0
- package/src/api/capabilities.router.ts +226 -0
- package/src/api/core/__tests__/auth-router-totp.spec.ts +256 -0
- package/src/api/core/addon-settings.router.ts +124 -0
- package/src/api/core/agents.router.ts +87 -0
- package/src/api/core/auth.router.ts +303 -0
- package/src/api/core/cap-providers.ts +993 -0
- package/src/api/core/capabilities.router.ts +119 -0
- package/src/api/core/collection-preference.ts +40 -0
- package/src/api/core/event-bus-proxy.router.ts +45 -0
- package/src/api/core/hwaccel.router.ts +81 -0
- package/src/api/core/live-events.router.ts +60 -0
- package/src/api/core/logs.router.ts +162 -0
- package/src/api/core/notifications.router.ts +65 -0
- package/src/api/core/repl.router.ts +41 -0
- package/src/api/core/settings-backend.router.ts +142 -0
- package/src/api/core/stream-probe.router.ts +57 -0
- package/src/api/core/system-events.router.ts +116 -0
- package/src/api/health/health.routes.ts +123 -0
- package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +52 -0
- package/src/api/oauth2/consent-page.ts +42 -0
- package/src/api/oauth2/oauth2-routes.ts +248 -0
- package/src/api/trpc/__tests__/scope-access-device.spec.ts +223 -0
- package/src/api/trpc/__tests__/scope-access.spec.ts +107 -0
- package/src/api/trpc/cap-mount-helpers.ts +225 -0
- package/src/api/trpc/core-cap-bridge.ts +152 -0
- package/src/api/trpc/generated-cap-mounts.ts +707 -0
- package/src/api/trpc/generated-cap-routers.ts +6340 -0
- package/src/api/trpc/scope-access.ts +110 -0
- package/src/api/trpc/trpc.context.ts +255 -0
- package/src/api/trpc/trpc.middleware.ts +140 -0
- package/src/api/trpc/trpc.router.ts +275 -0
- package/src/auth/session-cookie.ts +44 -0
- package/src/boot/boot-config.ts +278 -0
- package/src/boot/post-boot.service.ts +103 -0
- package/src/core/addon/__tests__/addon-registry-capability.test.ts +53 -0
- package/src/core/addon/addon-package.service.ts +1684 -0
- package/src/core/addon/addon-registry.service.ts +2926 -0
- package/src/core/addon/addon-search.service.ts +90 -0
- package/src/core/addon/addon-settings-provider.ts +276 -0
- package/src/core/addon/addon.tokens.ts +2 -0
- package/src/core/addon-bridge/addon-bridge.service.ts +125 -0
- package/src/core/addon-pages/addon-pages.service.spec.ts +117 -0
- package/src/core/addon-pages/addon-pages.service.ts +80 -0
- package/src/core/addon-widgets/addon-widgets.service.ts +92 -0
- package/src/core/agent/agent-registry.service.ts +507 -0
- package/src/core/auth/auth.service.spec.ts +88 -0
- package/src/core/auth/auth.service.ts +8 -0
- package/src/core/capability/capability.service.ts +57 -0
- package/src/core/config/config.schema.ts +3 -0
- package/src/core/config/config.service.spec.ts +175 -0
- package/src/core/config/config.service.ts +7 -0
- package/src/core/events/event-bus.service.spec.ts +212 -0
- package/src/core/events/event-bus.service.ts +85 -0
- package/src/core/feature/feature.service.spec.ts +96 -0
- package/src/core/feature/feature.service.ts +8 -0
- package/src/core/lifecycle/lifecycle-state-machine.spec.ts +168 -0
- package/src/core/lifecycle/lifecycle-state-machine.ts +3 -0
- package/src/core/logging/log-ring-buffer.ts +3 -0
- package/src/core/logging/logging.service.spec.ts +247 -0
- package/src/core/logging/logging.service.ts +129 -0
- package/src/core/logging/scoped-logger.ts +3 -0
- package/src/core/moleculer/moleculer.service.ts +612 -0
- package/src/core/network/network-quality.service.spec.ts +47 -0
- package/src/core/network/network-quality.service.ts +5 -0
- package/src/core/notification/notification-wrapper.service.ts +36 -0
- package/src/core/notification/toast-wrapper.service.ts +31 -0
- package/src/core/provider/provider.tokens.ts +1 -0
- package/src/core/repl/repl-engine.service.spec.ts +417 -0
- package/src/core/repl/repl-engine.service.ts +156 -0
- package/src/core/storage/fs-storage-backend.spec.ts +70 -0
- package/src/core/storage/fs-storage-backend.ts +3 -0
- package/src/core/storage/settings-store.spec.ts +213 -0
- package/src/core/storage/settings-store.ts +2 -0
- package/src/core/storage/sql-schema.spec.ts +140 -0
- package/src/core/storage/sql-schema.ts +3 -0
- package/src/core/storage/storage-location-manager.spec.ts +121 -0
- package/src/core/storage/storage-location-manager.ts +3 -0
- package/src/core/storage/storage.service.spec.ts +73 -0
- package/src/core/storage/storage.service.ts +3 -0
- package/src/core/streaming/stream-probe.service.ts +212 -0
- package/src/core/topology/topology-emitter.service.ts +101 -0
- package/src/launcher.ts +309 -0
- package/src/main.ts +1049 -0
- package/src/manual-boot.ts +322 -0
- package/tsconfig.build.json +8 -0
- package/tsconfig.json +21 -0
- package/vitest.config.ts +26 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { NotificationService } from '@camstack/core'
|
|
2
|
+
import type { Notification } from '@camstack/types'
|
|
3
|
+
import { CapabilityService } from '../capability/capability.service'
|
|
4
|
+
import { LoggingService } from '../logging/logging.service'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* NestJS-injectable wrapper around the core NotificationService.
|
|
8
|
+
*
|
|
9
|
+
* Lazily creates the NotificationService on first use, wiring in the
|
|
10
|
+
* CapabilityRegistry for proxy-based output resolution.
|
|
11
|
+
*/
|
|
12
|
+
export class NotificationServiceWrapper {
|
|
13
|
+
private _service: NotificationService | null = null
|
|
14
|
+
|
|
15
|
+
constructor(
|
|
16
|
+
private readonly caps: CapabilityService,
|
|
17
|
+
private readonly logging: LoggingService,
|
|
18
|
+
) {}
|
|
19
|
+
|
|
20
|
+
get service(): NotificationService {
|
|
21
|
+
if (!this._service) {
|
|
22
|
+
this._service = new NotificationService(
|
|
23
|
+
this.logging.createLogger('notifications'),
|
|
24
|
+
)
|
|
25
|
+
const registry = this.caps.getRegistry()
|
|
26
|
+
if (registry) {
|
|
27
|
+
this._service.setRegistry(registry)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return this._service
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async notify(notification: Notification): Promise<void> {
|
|
34
|
+
return this.service.notify(notification)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { ToastService } from '@camstack/core'
|
|
2
|
+
import type { Toast } from '@camstack/types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* NestJS-injectable wrapper around the core ToastService.
|
|
6
|
+
*
|
|
7
|
+
* Provides toast broadcasting to connected UI clients.
|
|
8
|
+
*/
|
|
9
|
+
export class ToastServiceWrapper {
|
|
10
|
+
private readonly _service = new ToastService()
|
|
11
|
+
|
|
12
|
+
get service(): ToastService {
|
|
13
|
+
return this._service
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
broadcast(toast: Toast): void {
|
|
17
|
+
this._service.broadcast(toast)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
sendToUser(userId: string, toast: Toast): void {
|
|
21
|
+
this._service.sendToUser(userId, toast)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
subscribe(
|
|
25
|
+
connectionId: string,
|
|
26
|
+
userId: string,
|
|
27
|
+
callback: (toast: Toast) => void,
|
|
28
|
+
): () => void {
|
|
29
|
+
return this._service.subscribe(connectionId, userId, callback)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const PROVIDER_MANAGER = Symbol('PROVIDER_MANAGER')
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return -- test file: mock typing crosses generic boundaries */
|
|
2
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
3
|
+
/**
|
|
4
|
+
* ReplEngineService specs — exercises the actual current shape of the
|
|
5
|
+
* REPL service, including the SystemManager warm-boot path that
|
|
6
|
+
* recently hung the entire REPL when the broker tried to route
|
|
7
|
+
* `live.onEvent` through the Moleculer service registry.
|
|
8
|
+
*
|
|
9
|
+
* Mocks at the AddonRegistry boundary (the dependency of the service)
|
|
10
|
+
* — the rest is real: ReplEngine, SystemManager, EventBus adapter.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
13
|
+
import { ReplEngineService } from './repl-engine.service'
|
|
14
|
+
import { SystemEventBus } from '@camstack/core'
|
|
15
|
+
import type { ReplSessionContext } from '@camstack/core'
|
|
16
|
+
import type { DeviceBinding } from '@camstack/types'
|
|
17
|
+
import { DeviceType } from '@camstack/types'
|
|
18
|
+
|
|
19
|
+
// ── In-process broker api fixture ─────────────────────────────────────
|
|
20
|
+
//
|
|
21
|
+
// Mimics what `addonRegistry.getBrokerApi()` returns: a plain object
|
|
22
|
+
// with `<cap>.<method>.{query|mutate}` shape that resolves locally.
|
|
23
|
+
// The REPL service merges `live.onEvent` via the EventBus adapter on
|
|
24
|
+
// top of this base.
|
|
25
|
+
|
|
26
|
+
interface BrokerApiState {
|
|
27
|
+
bindings: Map<number, DeviceBinding>
|
|
28
|
+
snapshots: Record<string, Record<string, Record<string, unknown>>>
|
|
29
|
+
devices: Map<number, {
|
|
30
|
+
id: number
|
|
31
|
+
stableId: string
|
|
32
|
+
addonId: string
|
|
33
|
+
type: DeviceType
|
|
34
|
+
name: string
|
|
35
|
+
parentDeviceId: number | null
|
|
36
|
+
role: string | null
|
|
37
|
+
online: boolean
|
|
38
|
+
features: string[]
|
|
39
|
+
isCamera: boolean
|
|
40
|
+
config: Record<string, unknown>
|
|
41
|
+
}>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function makeBrokerApi(state: BrokerApiState): unknown {
|
|
45
|
+
return {
|
|
46
|
+
deviceManager: {
|
|
47
|
+
getAllBindings: {
|
|
48
|
+
query: vi.fn(async () => Array.from(state.bindings.values())),
|
|
49
|
+
},
|
|
50
|
+
listAll: {
|
|
51
|
+
query: vi.fn(async () => Array.from(state.devices.values())),
|
|
52
|
+
},
|
|
53
|
+
getBindings: {
|
|
54
|
+
query: vi.fn(async ({ deviceId }: { deviceId: number }) =>
|
|
55
|
+
state.bindings.get(deviceId) ?? { deviceId, entries: [] }),
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
deviceState: {
|
|
59
|
+
getCapSlice: {
|
|
60
|
+
query: vi.fn(async ({ deviceId, capName }: { deviceId: number; capName: string }) =>
|
|
61
|
+
state.snapshots[String(deviceId)]?.[capName] ?? null),
|
|
62
|
+
},
|
|
63
|
+
getAllSnapshots: {
|
|
64
|
+
query: vi.fn(async () => state.snapshots),
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Harness ───────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
interface Harness {
|
|
73
|
+
service: ReplEngineService
|
|
74
|
+
state: BrokerApiState
|
|
75
|
+
eventBus: SystemEventBus
|
|
76
|
+
/** Re-fetch the broker api spies for assertion. */
|
|
77
|
+
brokerApi: any
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function makeHarness(seed?: Partial<BrokerApiState>): Harness {
|
|
81
|
+
// Reset the static SystemMirror singleton so each test gets a fresh
|
|
82
|
+
// warm-boot. The service caches it in static class fields — accept
|
|
83
|
+
// the test-only reach-in to keep tests isolated.
|
|
84
|
+
;(ReplEngineService as any).systemMirror = null
|
|
85
|
+
;(ReplEngineService as any).systemMirrorInit = null
|
|
86
|
+
|
|
87
|
+
const state: BrokerApiState = {
|
|
88
|
+
bindings: seed?.bindings ?? new Map(),
|
|
89
|
+
snapshots: seed?.snapshots ?? {},
|
|
90
|
+
devices: seed?.devices ?? new Map(),
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const brokerApi = makeBrokerApi(state)
|
|
94
|
+
const eventBus = new SystemEventBus(1000)
|
|
95
|
+
|
|
96
|
+
// Real EventBusService stub — the service only calls .subscribe.
|
|
97
|
+
const eventBusService = {
|
|
98
|
+
subscribe: (filter: any, handler: any) => eventBus.subscribe(filter, handler),
|
|
99
|
+
emit: (evt: any) => eventBus.emit(evt),
|
|
100
|
+
getRecent: () => [],
|
|
101
|
+
} as any
|
|
102
|
+
|
|
103
|
+
const deviceRegistry = {
|
|
104
|
+
getAll: () => Array.from(state.devices.values()),
|
|
105
|
+
getById: (id: number) => state.devices.get(id) ?? null,
|
|
106
|
+
getAllForAddon: (addonId: string) =>
|
|
107
|
+
Array.from(state.devices.values()).filter((d) => d.addonId === addonId),
|
|
108
|
+
getAllWithAddonId: () =>
|
|
109
|
+
Array.from(state.devices.values()).map((d) => ({ addonId: d.addonId, device: d })),
|
|
110
|
+
getAddonId: (id: number) => state.devices.get(id)?.addonId ?? null,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const integrationRegistry = {
|
|
114
|
+
listIntegrations: vi.fn(async () => []),
|
|
115
|
+
getIntegration: vi.fn(async () => null),
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const addonRegistry = {
|
|
119
|
+
getBrokerApi: () => brokerApi,
|
|
120
|
+
getDeviceRegistry: () => deviceRegistry,
|
|
121
|
+
getIntegrationRegistry: () => integrationRegistry,
|
|
122
|
+
listAddons: () => [],
|
|
123
|
+
} as any
|
|
124
|
+
|
|
125
|
+
const loggingService = {
|
|
126
|
+
createLogger: () => ({ info: vi.fn(), child: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() }),
|
|
127
|
+
} as any
|
|
128
|
+
|
|
129
|
+
const service = new ReplEngineService(addonRegistry, eventBusService, loggingService)
|
|
130
|
+
return { service, state, eventBus, brokerApi }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const mkBinding = (deviceId: number, capNames: string[]): DeviceBinding => ({
|
|
134
|
+
deviceId,
|
|
135
|
+
entries: capNames.map((capName) => ({
|
|
136
|
+
capName,
|
|
137
|
+
kind: 'native' as const,
|
|
138
|
+
providerAddonId: 'test',
|
|
139
|
+
providerNodeId: 'hub',
|
|
140
|
+
nativeAddonId: 'test',
|
|
141
|
+
})),
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
const mkDevice = (id: number, overrides: Partial<BrokerApiState['devices'] extends Map<number, infer V> ? V : never> = {}): BrokerApiState['devices'] extends Map<number, infer V> ? V : never => ({
|
|
145
|
+
id,
|
|
146
|
+
stableId: `stable-${id}`,
|
|
147
|
+
addonId: 'addon-test',
|
|
148
|
+
type: DeviceType.Camera,
|
|
149
|
+
name: `Device ${id}`,
|
|
150
|
+
parentDeviceId: null,
|
|
151
|
+
role: null,
|
|
152
|
+
online: true,
|
|
153
|
+
features: [],
|
|
154
|
+
isCamera: true,
|
|
155
|
+
config: {},
|
|
156
|
+
...overrides,
|
|
157
|
+
} as any)
|
|
158
|
+
|
|
159
|
+
const SYSTEM_CTX: ReplSessionContext = { scope: { type: 'system' }, variables: {} }
|
|
160
|
+
|
|
161
|
+
// ── Tests ─────────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
describe('ReplEngineService — basic eval', () => {
|
|
164
|
+
let h: Harness
|
|
165
|
+
beforeEach(() => { h = makeHarness() })
|
|
166
|
+
|
|
167
|
+
it('evaluates simple arithmetic expressions', async () => {
|
|
168
|
+
const r = await h.service.execute('1 + 1', SYSTEM_CTX)
|
|
169
|
+
expect(r.type).toBe('value')
|
|
170
|
+
expect(r.output).toBe('2')
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('returns void for variable declarations', async () => {
|
|
174
|
+
const r = await h.service.execute('const x = 42', SYSTEM_CTX)
|
|
175
|
+
expect(r.type).toBe('void')
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('returns error type when user code throws', async () => {
|
|
179
|
+
const r = await h.service.execute("throw new Error('boom')", SYSTEM_CTX)
|
|
180
|
+
expect(r.type).toBe('error')
|
|
181
|
+
expect(r.output).toContain('boom')
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('reports duration for every eval', async () => {
|
|
185
|
+
const r = await h.service.execute('1 + 1', SYSTEM_CTX)
|
|
186
|
+
expect(r.duration).toBeGreaterThanOrEqual(0)
|
|
187
|
+
expect(r.duration).toBeLessThan(5_000)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('isolates blocked globals — fetch / setTimeout / require unavailable', async () => {
|
|
191
|
+
const fetchR = await h.service.execute('typeof fetch', SYSTEM_CTX)
|
|
192
|
+
expect(fetchR.output).toBe("'undefined'")
|
|
193
|
+
const reqR = await h.service.execute('typeof require', SYSTEM_CTX)
|
|
194
|
+
expect(reqR.output).toBe("'undefined'")
|
|
195
|
+
const setTimeoutR = await h.service.execute('typeof setTimeout', SYSTEM_CTX)
|
|
196
|
+
expect(setTimeoutR.output).toBe("'undefined'")
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('exposes JSON / Math / Date as standard globals', async () => {
|
|
200
|
+
const json = await h.service.execute('JSON.stringify({a:1})', SYSTEM_CTX)
|
|
201
|
+
expect(json.output).toContain('"a":1')
|
|
202
|
+
const math = await h.service.execute('Math.max(1, 5, 3)', SYSTEM_CTX)
|
|
203
|
+
expect(math.output).toBe('5')
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
describe('ReplEngineService — system scope sandbox', () => {
|
|
208
|
+
let h: Harness
|
|
209
|
+
beforeEach(() => {
|
|
210
|
+
h = makeHarness({
|
|
211
|
+
bindings: new Map([[1, mkBinding(1, ['battery'])]]),
|
|
212
|
+
devices: new Map([[1, mkDevice(1, { name: 'Test Cam' })]]),
|
|
213
|
+
snapshots: { '1': { battery: { sleeping: true, percentage: 50 } } },
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('binds `sm` to the warm-booted SystemManager', async () => {
|
|
218
|
+
const r = await h.service.execute('typeof sm', SYSTEM_CTX)
|
|
219
|
+
expect(r.output).toBe("'object'")
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('sm.getDeviceById returns a typed proxy with sync state', async () => {
|
|
223
|
+
const r = await h.service.execute('sm.getDeviceById(1).state.battery.value.sleeping', SYSTEM_CTX)
|
|
224
|
+
expect(r.type).toBe('value')
|
|
225
|
+
expect(r.output).toBe('true')
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('sm.getDeviceByName resolves the metadata mirror', async () => {
|
|
229
|
+
const r = await h.service.execute("sm.getDeviceByName('Test Cam').deviceId", SYSTEM_CTX)
|
|
230
|
+
expect(r.output).toBe('1')
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('sm.summary returns counts', async () => {
|
|
234
|
+
const r = await h.service.execute('sm.summary().totalDevices', SYSTEM_CTX)
|
|
235
|
+
expect(r.output).toBe('1')
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('sm.query filters by addonId + caps + online', async () => {
|
|
239
|
+
h.state.bindings.set(2, mkBinding(2, ['snapshot']))
|
|
240
|
+
h.state.devices.set(2, mkDevice(2, { name: 'Cam 2', addonId: 'reolink', online: true }))
|
|
241
|
+
h.state.devices.set(1, mkDevice(1, { name: 'Cam 1', addonId: 'reolink', online: false }))
|
|
242
|
+
// Re-warm-boot the SM so the new devices show up.
|
|
243
|
+
;(ReplEngineService as any).systemMirror = null
|
|
244
|
+
;(ReplEngineService as any).systemMirrorInit = null
|
|
245
|
+
|
|
246
|
+
const r = await h.service.execute(
|
|
247
|
+
"sm.query({ addonId: 'reolink', online: true }).map(d => d.deviceId)",
|
|
248
|
+
SYSTEM_CTX,
|
|
249
|
+
)
|
|
250
|
+
expect(r.output).toContain('2')
|
|
251
|
+
expect(r.output).not.toContain('1,')
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('legacy variables remain accessible (backward-compat)', async () => {
|
|
255
|
+
const r1 = await h.service.execute('typeof addonRegistry', SYSTEM_CTX)
|
|
256
|
+
expect(r1.output).toBe("'object'")
|
|
257
|
+
const r2 = await h.service.execute('typeof eventBus', SYSTEM_CTX)
|
|
258
|
+
expect(r2.output).toBe("'object'")
|
|
259
|
+
const r3 = await h.service.execute('typeof getDevice', SYSTEM_CTX)
|
|
260
|
+
expect(r3.output).toBe("'function'")
|
|
261
|
+
})
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
describe('ReplEngineService — device scope sandbox', () => {
|
|
265
|
+
const DEVICE_CTX: ReplSessionContext = {
|
|
266
|
+
scope: { type: 'device', deviceId: 8 },
|
|
267
|
+
variables: {},
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
let h: Harness
|
|
271
|
+
beforeEach(() => {
|
|
272
|
+
h = makeHarness({
|
|
273
|
+
bindings: new Map([[8, mkBinding(8, ['battery', 'snapshot'])]]),
|
|
274
|
+
devices: new Map([[8, mkDevice(8, { name: 'Sala', addonId: 'reolink' })]]),
|
|
275
|
+
snapshots: { '8': { battery: { sleeping: false, percentage: 88 } } },
|
|
276
|
+
})
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it('pre-binds `device` as a DeviceProxy with sync state', async () => {
|
|
280
|
+
const r = await h.service.execute('device.state.battery.value.percentage', DEVICE_CTX)
|
|
281
|
+
expect(r.type).toBe('value')
|
|
282
|
+
expect(r.output).toBe('88')
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it('exposes deviceId, info, sm, rawDevice', async () => {
|
|
286
|
+
const a = await h.service.execute('deviceId', DEVICE_CTX)
|
|
287
|
+
expect(a.output).toBe('8')
|
|
288
|
+
const b = await h.service.execute('info.name', DEVICE_CTX)
|
|
289
|
+
expect(b.output).toBe("'Sala'")
|
|
290
|
+
const c = await h.service.execute('typeof sm', DEVICE_CTX)
|
|
291
|
+
expect(c.output).toBe("'object'")
|
|
292
|
+
const d = await h.service.execute('typeof rawDevice', DEVICE_CTX)
|
|
293
|
+
// rawDevice is the raw IDevice from registry — null in our harness for device 8 (we use plain objects)
|
|
294
|
+
// but the type check ensures the binding is in scope.
|
|
295
|
+
expect(['undefined', "'object'"]).toContain(d.output)
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it('device proxy exposes .state for every cap with runtimeState', async () => {
|
|
299
|
+
const r = await h.service.execute('typeof device.state.battery.subscribe', DEVICE_CTX)
|
|
300
|
+
expect(r.output).toBe("'function'")
|
|
301
|
+
const r2 = await h.service.execute('typeof device.state.motion.value', DEVICE_CTX)
|
|
302
|
+
expect(r2.output).toBe("'undefined'")
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
it('device proxy exposes cap method dispatchers', async () => {
|
|
306
|
+
const r = await h.service.execute('typeof device.snapshot.getSnapshot', DEVICE_CTX)
|
|
307
|
+
expect(r.output).toBe("'function'")
|
|
308
|
+
})
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
describe('ReplEngineService — SystemManager warm-boot resilience', () => {
|
|
312
|
+
it('warm-boot does NOT hang on `live.onEvent` (regression — broker can\'t route)', async () => {
|
|
313
|
+
// The bug: `getBrokerApi().live.onEvent.subscribe(...)` polls
|
|
314
|
+
// forever for a Moleculer service that doesn't exist. The fix
|
|
315
|
+
// injects a direct EventBus adapter for `live` so SM init never
|
|
316
|
+
// touches the broker for subscriptions.
|
|
317
|
+
const h = makeHarness({
|
|
318
|
+
bindings: new Map([[1, mkBinding(1, ['battery'])]]),
|
|
319
|
+
devices: new Map([[1, mkDevice(1)]]),
|
|
320
|
+
snapshots: {},
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
// 5 second hard ceiling — way above the 15s SM timeout but below
|
|
324
|
+
// the broker's infinite poll. If the bug regresses this test
|
|
325
|
+
// hangs the runner, not silently passes.
|
|
326
|
+
const result = await Promise.race([
|
|
327
|
+
h.service.execute('typeof sm', SYSTEM_CTX),
|
|
328
|
+
new Promise<never>((_, reject) =>
|
|
329
|
+
setTimeout(() => reject(new Error('test ceiling — REPL eval hung')), 5_000),
|
|
330
|
+
),
|
|
331
|
+
])
|
|
332
|
+
expect(result.type).toBe('value')
|
|
333
|
+
expect(result.output).toBe("'object'")
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
it('first eval triggers warm-boot; subsequent evals reuse the cached SystemManager', async () => {
|
|
337
|
+
const h = makeHarness({
|
|
338
|
+
bindings: new Map([[1, mkBinding(1, ['battery'])]]),
|
|
339
|
+
devices: new Map([[1, mkDevice(1)]]),
|
|
340
|
+
snapshots: {},
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
await h.service.execute('typeof sm', SYSTEM_CTX)
|
|
344
|
+
await h.service.execute('typeof sm', SYSTEM_CTX)
|
|
345
|
+
await h.service.execute('typeof sm', SYSTEM_CTX)
|
|
346
|
+
|
|
347
|
+
expect(h.brokerApi.deviceManager.getAllBindings.query).toHaveBeenCalledTimes(1)
|
|
348
|
+
expect(h.brokerApi.deviceState.getAllSnapshots.query).toHaveBeenCalledTimes(1)
|
|
349
|
+
expect(h.brokerApi.deviceManager.listAll.query).toHaveBeenCalledTimes(1)
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
it('live.onEvent uses the in-process EventBus, not the broker', async () => {
|
|
353
|
+
const h = makeHarness({
|
|
354
|
+
bindings: new Map([[1, mkBinding(1, ['battery'])]]),
|
|
355
|
+
devices: new Map([[1, mkDevice(1)]]),
|
|
356
|
+
snapshots: { '1': { battery: { sleeping: true, percentage: 50 } } },
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
// Boot SM via first eval.
|
|
360
|
+
await h.service.execute('typeof sm', SYSTEM_CTX)
|
|
361
|
+
|
|
362
|
+
// Fire an in-process state-change event — SM mirror should pick it up.
|
|
363
|
+
h.eventBus.emit({
|
|
364
|
+
id: 'test-1',
|
|
365
|
+
timestamp: Date.now(),
|
|
366
|
+
category: 'device.state-changed',
|
|
367
|
+
source: { type: 'device', id: 1, deviceId: 1 },
|
|
368
|
+
data: { deviceId: 1, capName: 'battery', slice: { sleeping: false, percentage: 90 } },
|
|
369
|
+
})
|
|
370
|
+
// Microtask flush.
|
|
371
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
372
|
+
|
|
373
|
+
const r = await h.service.execute('sm.getDeviceById(1).state.battery.value.sleeping', SYSTEM_CTX)
|
|
374
|
+
expect(r.output).toBe('false')
|
|
375
|
+
})
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
describe('ReplEngineService — error paths', () => {
|
|
379
|
+
let h: Harness
|
|
380
|
+
beforeEach(() => { h = makeHarness() })
|
|
381
|
+
|
|
382
|
+
it('returns error when accessing undefined variables', async () => {
|
|
383
|
+
const r = await h.service.execute('undefinedVariable.foo', SYSTEM_CTX)
|
|
384
|
+
expect(r.type).toBe('error')
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
it('error output does not leak the engine internals', async () => {
|
|
388
|
+
const r = await h.service.execute('throw new TypeError("user error")', SYSTEM_CTX)
|
|
389
|
+
expect(r.type).toBe('error')
|
|
390
|
+
expect(r.output).toContain('user error')
|
|
391
|
+
// Should NOT contain stack frames from repl-engine.ts itself.
|
|
392
|
+
expect(r.output).not.toContain('repl-engine.ts')
|
|
393
|
+
})
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
describe('ReplEngineService — completions', () => {
|
|
397
|
+
let h: Harness
|
|
398
|
+
beforeEach(() => {
|
|
399
|
+
h = makeHarness({
|
|
400
|
+
bindings: new Map([[1, mkBinding(1, ['battery'])]]),
|
|
401
|
+
devices: new Map([[1, mkDevice(1)]]),
|
|
402
|
+
snapshots: {},
|
|
403
|
+
})
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
it('returns sandbox keys when partial is empty', async () => {
|
|
407
|
+
const completions = await h.service.getCompletions('', SYSTEM_CTX)
|
|
408
|
+
expect(completions).toContain('sm')
|
|
409
|
+
expect(completions).toContain('JSON')
|
|
410
|
+
expect(completions).toContain('Math')
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
it('filters by partial prefix', async () => {
|
|
414
|
+
const completions = await h.service.getCompletions('sm', SYSTEM_CTX)
|
|
415
|
+
expect(completions).toContain('sm')
|
|
416
|
+
})
|
|
417
|
+
})
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { ReplEngine } from '@camstack/core'
|
|
2
|
+
import type { IReplContextProvider } from '@camstack/core'
|
|
3
|
+
import { SystemMirror, type SystemMirrorApi, type DeviceProxy } from '@camstack/types'
|
|
4
|
+
import { AddonRegistryService } from '../addon/addon-registry.service'
|
|
5
|
+
import { EventBusService } from '../events/event-bus.service'
|
|
6
|
+
import { LoggingService } from '../logging/logging.service'
|
|
7
|
+
|
|
8
|
+
export class ReplEngineService extends ReplEngine {
|
|
9
|
+
/**
|
|
10
|
+
* Lazily-instantiated `SystemMirror` shared across REPL sessions.
|
|
11
|
+
* Holds a single warm-boot mirror that every `sm.getDeviceById(id)`
|
|
12
|
+
* lookup serves from. Init runs at first access (Promise cached so
|
|
13
|
+
* concurrent sessions don't double-fetch).
|
|
14
|
+
*/
|
|
15
|
+
private static systemMirror: SystemMirror | null = null
|
|
16
|
+
private static systemMirrorInit: Promise<SystemMirror> | null = null
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Build a `SystemMirrorApi` for in-process server use. The cap-method
|
|
20
|
+
* surface (deviceManager / deviceState queries) goes through the
|
|
21
|
+
* standard broker tRPC client — those resolve locally via
|
|
22
|
+
* `localProviderLink`. The `live.onEvent` channel is NOT a cap, so
|
|
23
|
+
* the broker can't route it; we synthesize the same shape over the
|
|
24
|
+
* local `EventBusService` so SystemMirror subscriptions work without
|
|
25
|
+
* crossing the network boundary or polling the broker for a
|
|
26
|
+
* non-existent `live` service.
|
|
27
|
+
*/
|
|
28
|
+
private static buildInProcessApi(
|
|
29
|
+
addonRegistry: AddonRegistryService,
|
|
30
|
+
eventBus: EventBusService,
|
|
31
|
+
): SystemMirrorApi {
|
|
32
|
+
const baseApi = addonRegistry.getBrokerApi() as unknown as SystemMirrorApi
|
|
33
|
+
return {
|
|
34
|
+
...baseApi,
|
|
35
|
+
live: {
|
|
36
|
+
onEvent: {
|
|
37
|
+
subscribe: (input, opts) => {
|
|
38
|
+
const off = eventBus.subscribe(
|
|
39
|
+
{ category: input.category },
|
|
40
|
+
(evt) => {
|
|
41
|
+
try {
|
|
42
|
+
opts.onData({ data: evt.data })
|
|
43
|
+
} catch (err) {
|
|
44
|
+
opts.onError?.(err)
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
)
|
|
48
|
+
return { unsubscribe: off }
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private static getOrInitSystemMirror(
|
|
56
|
+
addonRegistry: AddonRegistryService,
|
|
57
|
+
eventBus: EventBusService,
|
|
58
|
+
): Promise<SystemMirror> {
|
|
59
|
+
if (this.systemMirror) return Promise.resolve(this.systemMirror)
|
|
60
|
+
if (!this.systemMirrorInit) {
|
|
61
|
+
const api = this.buildInProcessApi(addonRegistry, eventBus)
|
|
62
|
+
const sm = new SystemMirror(api)
|
|
63
|
+
this.systemMirrorInit = sm.init().then(() => {
|
|
64
|
+
this.systemMirror = sm
|
|
65
|
+
return sm
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
return this.systemMirrorInit
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
constructor(
|
|
72
|
+
addonRegistry: AddonRegistryService,
|
|
73
|
+
eventBus: EventBusService,
|
|
74
|
+
_loggingService: LoggingService,
|
|
75
|
+
) {
|
|
76
|
+
const contextProvider: IReplContextProvider = {
|
|
77
|
+
async getSystemSandbox() {
|
|
78
|
+
const integrationRegistry = addonRegistry.getIntegrationRegistry()
|
|
79
|
+
const deviceRegistry = addonRegistry.getDeviceRegistry()
|
|
80
|
+
// Warm-boot the SystemMirror so `sm.getDeviceById(id)` is sync
|
|
81
|
+
// for the first user expression.
|
|
82
|
+
const sm = await ReplEngineService.getOrInitSystemMirror(addonRegistry, eventBus)
|
|
83
|
+
return {
|
|
84
|
+
// ── New canonical API ───────────────────────────────────────
|
|
85
|
+
/**
|
|
86
|
+
* SystemMirror — the cap-driven, reactive view of every
|
|
87
|
+
* device. Sync `getDeviceById(id)`, typed `state.<cap>.value`
|
|
88
|
+
* reads, full method dispatch, query helpers.
|
|
89
|
+
*/
|
|
90
|
+
sm,
|
|
91
|
+
// ── Legacy (kept for backward-compat) ──────────────────────
|
|
92
|
+
addonRegistry,
|
|
93
|
+
eventBus,
|
|
94
|
+
integrationRegistry,
|
|
95
|
+
devices: () => deviceRegistry.getAll(),
|
|
96
|
+
integrations: async () => (await integrationRegistry?.listIntegrations()) ?? [],
|
|
97
|
+
addons: () => addonRegistry.listAddons(),
|
|
98
|
+
getDevice: (id: number) => deviceRegistry.getById(id),
|
|
99
|
+
getIntegration: async (id: string) => (await integrationRegistry?.getIntegration(id)) ?? null,
|
|
100
|
+
getSystemMirror: () => Promise.resolve(sm),
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
async getDeviceSandbox(deviceId: number) {
|
|
104
|
+
const deviceRegistry = addonRegistry.getDeviceRegistry()
|
|
105
|
+
const rawDevice = deviceRegistry.getById(deviceId)
|
|
106
|
+
const sm = await ReplEngineService.getOrInitSystemMirror(addonRegistry, eventBus)
|
|
107
|
+
// `device` is the typed DeviceProxy backed by the SystemMirror
|
|
108
|
+
// mirror — same shape as `sm.getDeviceById(deviceId)`. Sync
|
|
109
|
+
// state reads + cap-method dispatch via the wrapper chain.
|
|
110
|
+
const device: DeviceProxy | null = sm.getDeviceById(deviceId)
|
|
111
|
+
return {
|
|
112
|
+
/** SystemMirror — full cluster view. */
|
|
113
|
+
sm,
|
|
114
|
+
/**
|
|
115
|
+
* The current device as a DeviceProxy. Sync state reads
|
|
116
|
+
* (`device.state.battery.value`) + async cap methods
|
|
117
|
+
* (`await device.snapshot.getSnapshot({})`).
|
|
118
|
+
*/
|
|
119
|
+
device,
|
|
120
|
+
/** Numeric device id (same as URL). */
|
|
121
|
+
deviceId,
|
|
122
|
+
/** Device metadata (name, addonId, type, online, …). */
|
|
123
|
+
info: sm.getDeviceInfo(deviceId),
|
|
124
|
+
/** Raw IDevice instance — escape hatch for legacy access.
|
|
125
|
+
* Prefer `device` (DeviceProxy) for new code. */
|
|
126
|
+
rawDevice,
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
getProviderSandbox(addonId: string) {
|
|
130
|
+
const integrationRegistry = addonRegistry.getIntegrationRegistry()
|
|
131
|
+
const deviceRegistry = addonRegistry.getDeviceRegistry()
|
|
132
|
+
const devices = deviceRegistry.getAllForAddon(addonId)
|
|
133
|
+
return {
|
|
134
|
+
getIntegration: () => integrationRegistry?.getIntegration(addonId) ?? Promise.resolve(null),
|
|
135
|
+
devices,
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
getAddonSandbox(addonId: string) {
|
|
139
|
+
// REPL exposes addon metadata only — never the live in-process
|
|
140
|
+
// instance. Direct addon access only works for hub-local addons
|
|
141
|
+
// and breaks for remote-agent addons. To invoke addon behaviour
|
|
142
|
+
// from the REPL, use the cap router via tRPC instead.
|
|
143
|
+
const entry = addonRegistry.listAddons().find((e) => e.manifest.id === addonId)
|
|
144
|
+
return {
|
|
145
|
+
manifest: entry?.manifest,
|
|
146
|
+
declaration: entry?.declaration,
|
|
147
|
+
source: entry?.source,
|
|
148
|
+
installSource: entry?.installSource,
|
|
149
|
+
process: entry?.process,
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
super(contextProvider)
|
|
155
|
+
}
|
|
156
|
+
}
|